mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 14:28:38 +03:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a04750ada | ||
|
|
70bd454d32 | ||
|
|
a9fed841e0 | ||
|
|
71972a5308 | ||
|
|
9d8a6cc9bb | ||
|
|
833c85f373 | ||
|
|
6f361cd476 | ||
|
|
2c083856e0 | ||
|
|
43511decc9 | ||
|
|
8dd2059be3 | ||
|
|
7b1646f58c | ||
|
|
e620944150 | ||
|
|
66316f21da | ||
|
|
55ecda275d | ||
|
|
411d6061ae |
@@ -173,7 +173,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Test WebUI
|
- name: Test WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: bun run test
|
run: bun run test:coverage
|
||||||
|
|
||||||
- name: Build WebUI
|
- name: Build WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
|
|||||||
|
|
||||||
## 🌐 WebUI
|
## 🌐 WebUI
|
||||||
|
|
||||||
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, temporary chats, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||||
@@ -250,9 +250,10 @@ The WebUI ships **inside the published wheel** with no separate frontend build.
|
|||||||
Use it to:
|
Use it to:
|
||||||
|
|
||||||
- keep separate topics for different tasks and projects;
|
- keep separate topics for different tasks and projects;
|
||||||
|
- use temporary chats when a conversation should not be saved to history or memory;
|
||||||
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||||
- switch models and workspaces without leaving the conversation;
|
- switch models and workspaces without leaving the conversation;
|
||||||
- configure providers, chat channels, Apps, Skills, and Automations from one place.
|
- configure providers and chat channels, connect Apps, discover Skills, and manage Automations from one place.
|
||||||
|
|
||||||
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
|
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,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) |
|
| 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 |
|
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||||
| MCP | Add `tools.mcpServers` config |
|
| MCP | Add `tools.mcpServers` config |
|
||||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
|
||||||
|
|
||||||
Prefer existing registry/discovery patterns over ad hoc wiring.
|
Prefer existing registry/discovery patterns over ad hoc wiring.
|
||||||
|
|
||||||
|
|||||||
@@ -2306,6 +2306,52 @@ 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. |
|
| `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 after the plugin is explicitly enabled. Disabling a plugin removes both its skills and
|
||||||
|
MCP servers from the agent. A workspace skill wins when it has the same name as an enabled
|
||||||
|
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.
|
||||||
|
|
||||||
|
Treat enabled plugins as local code running with the nanobot user's privileges. Manifest
|
||||||
|
permissions are descriptive; nanobot does not currently enforce them with an OS sandbox.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The optional `extensions.dev.nanobot.logo` field points to a packaged PNG, JPEG, or WebP asset
|
||||||
|
such as `./assets/icon.png`. nanobot only reads contained raster files up to 256 KiB and embeds
|
||||||
|
them locally in the Apps catalog; invalid or missing assets fall back to the plugin initials.
|
||||||
|
|
||||||
|
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
|
||||||
|
its catalog adapter, then writes and enables 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 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.
|
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.
|
||||||
|
|||||||
@@ -270,6 +270,12 @@ http://127.0.0.1:8765
|
|||||||
|
|
||||||
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
|
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
|---|---|
|
||||||
|
| A temporary chat disappeared after a reload or reconnect | This is expected. Temporary chats exist only for the current WebUI connection and are not saved to history or memory. Use a regular topic for anything you need to retain. |
|
||||||
|
| A skills.sh install says that `npx` is required | Install Node.js with `npx` on the gateway machine, or choose a SkillHub skill that does not require `npx`. |
|
||||||
|
| A remote browser says skill installation is disabled | Install from a same-machine WebUI. For a private deployment where every authenticated user is trusted to install third-party skill instructions or scripts, explicitly enable `tools.webuiAllowRemotePackageInstall`. |
|
||||||
|
|
||||||
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
|
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
|
||||||
|
|
||||||
## Chat App Problems
|
## Chat App Problems
|
||||||
|
|||||||
+68
-19
@@ -1,10 +1,10 @@
|
|||||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||||
|
|
||||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
<!-- Meta description: Run nanobot from a browser WebUI with persistent and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
|
||||||
|
|
||||||
The WebUI is nanobot's browser workbench for persistent topics, visible
|
The WebUI is nanobot's browser workbench for persistent topics, temporary
|
||||||
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
chats, visible agent activity, workspace controls, Apps, skill discovery,
|
||||||
one place.
|
settings, and Automations in one place.
|
||||||
|
|
||||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||||
the `webui/` source directory when you are changing the frontend itself.
|
the `webui/` source directory when you are changing the frontend itself.
|
||||||
@@ -72,14 +72,14 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
|
|||||||
|
|
||||||
| Area | Use it for |
|
| Area | Use it for |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Topics | Start, switch, search, fork, and delete browser topics |
|
| Topics | Start persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent topics |
|
||||||
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
||||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
|
| Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
|
||||||
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
||||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||||
| Skills | Inspect available built-in and workspace skills before relying on them |
|
| Skills | Inspect and manage installed skills, or discover skills from supported marketplaces |
|
||||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
||||||
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||||
|
|
||||||
@@ -90,6 +90,10 @@ workspace selection, and linked automations. Use a new topic when you want a
|
|||||||
separate context; use fork when you want to continue from an existing point
|
separate context; use fork when you want to continue from an existing point
|
||||||
without changing the original thread.
|
without changing the original thread.
|
||||||
|
|
||||||
|
Drag a topic within its current sidebar group to keep frequently used work in
|
||||||
|
your preferred order. Drag a topic from the sidebar into the composer when you
|
||||||
|
want to reference it in the next message instead of switching to it.
|
||||||
|
|
||||||
The message timeline shows both user-visible replies and agent activity. Long
|
The message timeline shows both user-visible replies and agent activity. Long
|
||||||
tool or reasoning sections can be expanded when you need the details.
|
tool or reasoning sections can be expanded when you need the details.
|
||||||
|
|
||||||
@@ -103,6 +107,28 @@ File previews follow the active session access mode. Restricted workspace access
|
|||||||
previews only files under the selected workspace. Full Access can preview files
|
previews only files under the selected workspace. Full Access can preview files
|
||||||
outside the workspace when that access mode is allowed by the gateway.
|
outside the workspace when that access mode is allowed by the gateway.
|
||||||
|
|
||||||
|
## Temporary Chats
|
||||||
|
|
||||||
|
Use a temporary chat for a conversation that should not be added to nanobot's
|
||||||
|
topic history or long-term memory:
|
||||||
|
|
||||||
|
1. Select **New topic**.
|
||||||
|
2. Select the **Temporary chat** control in the page header.
|
||||||
|
3. Send the first message.
|
||||||
|
|
||||||
|
You can keep more than one temporary chat open and switch between them under
|
||||||
|
**Temporary chats** in the sidebar while the current WebUI connection remains
|
||||||
|
open. Reloading or closing the page, restarting the gateway, or losing the
|
||||||
|
WebSocket connection ends all of them. They cannot be recovered afterward.
|
||||||
|
|
||||||
|
Temporary does not mean consequence-free. Requests still go to the configured
|
||||||
|
model provider, and tools can still change files, run commands, or affect
|
||||||
|
external services. Temporary chats always use the default workspace in
|
||||||
|
Restricted mode; the project picker and Full Access are unavailable. Commands
|
||||||
|
and tools that create durable goals, automations, or subagent work are also
|
||||||
|
unavailable. Use a regular topic when you need reusable context, scheduled work,
|
||||||
|
or a result you must retain.
|
||||||
|
|
||||||
## Workspace and Access
|
## Workspace and Access
|
||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
Use the workspace picker before starting project-specific work. This gives the
|
||||||
@@ -145,7 +171,8 @@ clients.
|
|||||||
The composer supports plain messages, image attachments, voice input when
|
The composer supports plain messages, image attachments, voice input when
|
||||||
transcription is configured, slash commands, and `@` mentions for installed Apps
|
transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||||
or MCP presets. Select another topic from the `@` menu to attach a stable
|
or MCP presets. Select another topic from the `@` menu to attach a stable
|
||||||
reference; plain text that happens to start with `@` does not attach history.
|
reference, or drag that topic from the sidebar into the composer. Plain text
|
||||||
|
that happens to start with `@` does not attach history.
|
||||||
Restricted chats offer topics from the same project, while Full Access chats can
|
Restricted chats offer topics from the same project, while Full Access chats can
|
||||||
reference any WebUI topic. Nanobot reads a referenced topic only when its history
|
reference any WebUI topic. Nanobot reads a referenced topic only when its history
|
||||||
is relevant and can link it in the response. The model badge shows the current
|
is relevant and can link it in the response. The model badge shows the current
|
||||||
@@ -204,10 +231,20 @@ After an App or integration is available, mention it from the composer with
|
|||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
The Skills view shows the skill instructions available to the agent, including
|
Open **Skills → Installed** to review built-in and workspace-provided skills.
|
||||||
built-in skills and workspace-provided skills. Check this view when you want to
|
You can search and filter them, inspect their instructions and setup
|
||||||
know whether nanobot already has a focused workflow for a task before you ask it
|
requirements, enable or disable them, and delete workspace skills you no longer
|
||||||
to perform that task.
|
want.
|
||||||
|
|
||||||
|
Open **Skills → Discover** to browse or search skills from skills.sh and
|
||||||
|
SkillHub. A marketplace skill is copied into the active agent workspace after
|
||||||
|
you confirm the installation. skills.sh installation requires Node.js with
|
||||||
|
`npx`; SkillHub installation does not.
|
||||||
|
|
||||||
|
Marketplace skills are third-party instructions and may include executable
|
||||||
|
scripts. Review the source and instructions before installing one, and enable
|
||||||
|
only skills you trust with the same files, tools, and credentials available to
|
||||||
|
your agent.
|
||||||
|
|
||||||
## Automations
|
## Automations
|
||||||
|
|
||||||
@@ -288,10 +325,17 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
|||||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||||
form.
|
form.
|
||||||
|
|
||||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
|
||||||
install missing nanobot support packages, such as adding a channel dependency,
|
capture only in secure contexts. Voice input works on same-machine localhost;
|
||||||
are blocked by default. To let trusted remote administrators change the Python
|
from another device, serve the WebUI over HTTPS with a certificate that device
|
||||||
environment through the WebUI, opt in explicitly:
|
trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
|
||||||
|
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
|
||||||
|
reverse proxy and use that proxy's HTTPS URL.
|
||||||
|
|
||||||
|
Remote WebUI clients with a valid token can view and use Apps and installed
|
||||||
|
skills. Actions that install missing nanobot support packages or third-party
|
||||||
|
marketplace skills are blocked by default. To let trusted remote administrators
|
||||||
|
perform those installations through the WebUI, opt in explicitly:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -302,12 +346,13 @@ environment through the WebUI, opt in explicitly:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use this only for a private deployment where every authenticated WebUI user is
|
Use this only for a private deployment where every authenticated WebUI user is
|
||||||
trusted to change the Python environment that nanobot runs in. If you publish
|
trusted to change nanobot's Python environment and install workspace skill
|
||||||
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
|
||||||
as remote access and leave package installs disabled unless that is intentional.
|
Cloudflare Tunnel, or a similar service, treat it as remote access and leave
|
||||||
|
package and skill installs disabled unless that is intentional.
|
||||||
|
|
||||||
Optional feature installs use pip's configured package index, including
|
Optional feature installs use pip's configured package index, including
|
||||||
`PIP_INDEX_URL`.
|
`PIP_INDEX_URL`. skills.sh marketplace installs use `npx` instead.
|
||||||
|
|
||||||
Leave remote package installs disabled when the WebUI is exposed beyond a
|
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||||
private, trusted network.
|
private, trusted network.
|
||||||
@@ -322,6 +367,10 @@ If the page does not open, check these in order:
|
|||||||
4. You are opening port `8765`, not the gateway health port.
|
4. You are opening port `8765`, not the gateway health port.
|
||||||
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
||||||
|
|
||||||
|
If voice input asks for a secure connection, use HTTPS with a certificate the
|
||||||
|
device trusts. Browsers do not expose microphone capture to
|
||||||
|
`http://<your-ip>` origins.
|
||||||
|
|
||||||
For detailed diagnostics, see
|
For detailed diagnostics, see
|
||||||
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
||||||
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
||||||
|
|||||||
@@ -480,6 +480,8 @@ class AgentLoop:
|
|||||||
config,
|
config,
|
||||||
provider_snapshot_loader,
|
provider_snapshot_loader,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@@ -494,7 +496,7 @@ class AgentLoop:
|
|||||||
provider_retry_mode=defaults.provider_retry_mode,
|
provider_retry_mode=defaults.provider_retry_mode,
|
||||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
|
||||||
channels_config=config.channels,
|
channels_config=config.channels,
|
||||||
timezone=defaults.timezone,
|
timezone=defaults.timezone,
|
||||||
unified_session=defaults.unified_session,
|
unified_session=defaults.unified_session,
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
"""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 filelock import FileLock
|
||||||
|
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"}
|
||||||
|
_SETUP_TIMEOUT_SECONDS = 600
|
||||||
|
_LOGO_SUFFIXES = {".jpeg", ".jpg", ".png", ".webp"}
|
||||||
|
_MAX_LOGO_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
logo: Path | None
|
||||||
|
permissions: tuple[str, ...]
|
||||||
|
install_command: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentPluginState:
|
||||||
|
"""Runtime state for one discovered Agent Plugin."""
|
||||||
|
|
||||||
|
plugin: AgentPlugin
|
||||||
|
mcp_servers: tuple[str, ...]
|
||||||
|
enabled: bool
|
||||||
|
setup_required: bool
|
||||||
|
|
||||||
|
|
||||||
|
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 enabled_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
|
||||||
|
"""Return skills from plugins the user has explicitly enabled."""
|
||||||
|
skills: list[AgentPluginSkill] = []
|
||||||
|
for plugin in discover_agent_plugins(workspace):
|
||||||
|
if _enabled(workspace, plugin.name):
|
||||||
|
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")),
|
||||||
|
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
|
||||||
|
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 discover_agent_plugin_states(workspace: Path) -> list[AgentPluginState]:
|
||||||
|
"""Return component and lifecycle state for discovered plugins."""
|
||||||
|
states: list[AgentPluginState] = []
|
||||||
|
for plugin in discover_agent_plugins(workspace):
|
||||||
|
states.append(
|
||||||
|
AgentPluginState(
|
||||||
|
plugin=plugin,
|
||||||
|
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
|
||||||
|
enabled=_enabled(workspace, plugin.name),
|
||||||
|
setup_required=bool(plugin.install_command)
|
||||||
|
and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return states
|
||||||
|
|
||||||
|
|
||||||
|
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> AgentPlugin:
|
||||||
|
"""Enable or disable one installed plugin."""
|
||||||
|
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)
|
||||||
|
with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10):
|
||||||
|
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)
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
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 _plugin_logo(value: object, plugin_root: Path) -> Path | None:
|
||||||
|
"""Resolve nanobot's optional packaged logo extension."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or not value.startswith("./"):
|
||||||
|
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||||
|
return None
|
||||||
|
logo = _contained_file(plugin_root / value[2:], plugin_root)
|
||||||
|
if logo is None or logo.suffix.lower() not in _LOGO_SUFFIXES:
|
||||||
|
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if logo.stat().st_size > _MAX_LOGO_BYTES:
|
||||||
|
logger.warning("Ignoring oversized Agent Plugin logo in '{}'", plugin_root)
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return logo
|
||||||
|
|
||||||
|
|
||||||
|
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=_SETUP_TIMEOUT_SECONDS,
|
||||||
|
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
|
||||||
+29
-8
@@ -60,11 +60,26 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
List of skill info dicts with 'name', 'path', 'source'.
|
List of skill info dicts with 'name', 'path', 'source'.
|
||||||
"""
|
"""
|
||||||
|
from nanobot.agent.plugins import enabled_agent_plugin_skills
|
||||||
|
|
||||||
|
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
||||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||||
workspace_names = {entry["name"] for entry in skills}
|
seen_names = {entry["name"] for entry in skills}
|
||||||
|
for plugin_skill in 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)
|
||||||
if self.builtin_skills and self.builtin_skills.exists():
|
if self.builtin_skills and self.builtin_skills.exists():
|
||||||
skills.extend(
|
skills.extend(
|
||||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.disabled_skills:
|
if self.disabled_skills:
|
||||||
@@ -84,13 +99,18 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Skill content or None if not found.
|
Skill content or None if not found.
|
||||||
"""
|
"""
|
||||||
roots = [self.workspace_skills]
|
workspace_path = self.workspace_skills / name / "SKILL.md"
|
||||||
|
if workspace_path.exists():
|
||||||
|
return workspace_path.read_text(encoding="utf-8")
|
||||||
|
from nanobot.agent.plugins import enabled_agent_plugin_skills
|
||||||
|
|
||||||
|
for plugin_skill in enabled_agent_plugin_skills(self.workspace):
|
||||||
|
if plugin_skill.name == name and plugin_skill.path.is_file():
|
||||||
|
return plugin_skill.path.read_text(encoding="utf-8")
|
||||||
if self.builtin_skills:
|
if self.builtin_skills:
|
||||||
roots.append(self.builtin_skills)
|
builtin_path = self.builtin_skills / name / "SKILL.md"
|
||||||
for root in roots:
|
if builtin_path.exists():
|
||||||
path = root / name / "SKILL.md"
|
return builtin_path.read_text(encoding="utf-8")
|
||||||
if path.exists():
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||||
@@ -145,6 +165,7 @@ class SkillsLoader:
|
|||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
groups = (
|
groups = (
|
||||||
("Workspace skills", "workspace", self.workspace_skills),
|
("Workspace skills", "workspace", self.workspace_skills),
|
||||||
|
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||||
("Built-in skills", "builtin", self.builtin_skills),
|
("Built-in skills", "builtin", self.builtin_skills),
|
||||||
)
|
)
|
||||||
for label, source, root in groups:
|
for label, source, root in groups:
|
||||||
|
|||||||
+24
-30
@@ -975,11 +975,8 @@ async def connect_mcp_servers(
|
|||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def open_single_server(
|
async def open_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
|
||||||
) -> tuple[str, AsyncExitStack | None]:
|
) -> bool:
|
||||||
server_stack = AsyncExitStack()
|
|
||||||
await server_stack.__aenter__()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transport_type = cfg.type
|
transport_type = cfg.type
|
||||||
if not transport_type:
|
if not transport_type:
|
||||||
@@ -991,8 +988,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
if transport_type in {"sse", "streamableHttp"}:
|
if transport_type in {"sse", "streamableHttp"}:
|
||||||
ok, error = validate_url_target(cfg.url)
|
ok, error = validate_url_target(cfg.url)
|
||||||
@@ -1003,8 +999,7 @@ async def connect_mcp_servers(
|
|||||||
_redact_url(cfg.url),
|
_redact_url(cfg.url),
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
if transport_type == "stdio":
|
if transport_type == "stdio":
|
||||||
command, args, env = _normalize_windows_stdio_command(
|
command, args, env = _normalize_windows_stdio_command(
|
||||||
@@ -1022,8 +1017,7 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
def httpx_client_factory(
|
def httpx_client_factory(
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
@@ -1050,8 +1044,7 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "streamableHttp":
|
elif transport_type == "streamableHttp":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
http_client = await server_stack.enter_async_context(
|
http_client = await server_stack.enter_async_context(
|
||||||
httpx.AsyncClient(
|
httpx.AsyncClient(
|
||||||
@@ -1067,8 +1060,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
read = _filter_malformed_mcp_progress_notifications(read, name)
|
read = _filter_malformed_mcp_progress_notifications(read, name)
|
||||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
session = await server_stack.enter_async_context(ClientSession(read, write))
|
||||||
@@ -1171,7 +1163,7 @@ async def connect_mcp_servers(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||||
)
|
)
|
||||||
return name, server_stack
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hint = ""
|
hint = ""
|
||||||
@@ -1191,9 +1183,7 @@ async def connect_mcp_servers(
|
|||||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||||
)
|
)
|
||||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||||
with suppress(Exception):
|
return False
|
||||||
await server_stack.aclose()
|
|
||||||
return name, None
|
|
||||||
|
|
||||||
async def connect_single_server(
|
async def connect_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig"
|
||||||
@@ -1203,30 +1193,30 @@ async def connect_mcp_servers(
|
|||||||
close_requested = asyncio.Event()
|
close_requested = asyncio.Event()
|
||||||
|
|
||||||
async def own_connection() -> None:
|
async def own_connection() -> None:
|
||||||
stack: AsyncExitStack | None = None
|
|
||||||
try:
|
try:
|
||||||
_, stack = await open_single_server(name, cfg)
|
async with AsyncExitStack() as stack:
|
||||||
if not ready.done():
|
connected = await open_single_server(name, cfg, stack)
|
||||||
ready.set_result(stack is not None)
|
if not ready.done():
|
||||||
if stack is not None:
|
ready.set_result(connected)
|
||||||
await close_requested.wait()
|
if connected:
|
||||||
|
await close_requested.wait()
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_exception(exc)
|
ready.set_exception(exc)
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
if stack is not None:
|
|
||||||
await stack.aclose()
|
|
||||||
|
|
||||||
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||||
connection = _OwnedMCPConnection(owner, close_requested)
|
connection = _OwnedMCPConnection(owner, close_requested)
|
||||||
try:
|
try:
|
||||||
connected = await ready
|
connected = await ready
|
||||||
except BaseException:
|
except BaseException as exc:
|
||||||
close_requested.set()
|
close_requested.set()
|
||||||
owner.cancel()
|
owner.cancel()
|
||||||
with suppress(BaseException):
|
with suppress(BaseException):
|
||||||
await asyncio.shield(owner)
|
await asyncio.shield(owner)
|
||||||
|
if isinstance(exc, asyncio.CancelledError) and not task_is_cancelling():
|
||||||
|
logger.warning("MCP server '{}': connection cancelled by server/SDK", name)
|
||||||
|
return name, None
|
||||||
raise
|
raise
|
||||||
if not connected:
|
if not connected:
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
@@ -1296,10 +1286,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config())
|
config = resolve_config_env_vars(load_config())
|
||||||
next_servers = dict(config.tools.mcp_servers)
|
next_servers = agent_plugin_mcp_servers(
|
||||||
|
config.workspace_path,
|
||||||
|
config.tools.mcp_servers,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -453,9 +453,9 @@ class WebSearchTool(Tool):
|
|||||||
|
|
||||||
async def _search_olostep(self, query: str, n: int) -> str:
|
async def _search_olostep(self, query: str, n: int) -> str:
|
||||||
try:
|
try:
|
||||||
from olostep import ( # pyright: ignore[reportMissingImports]
|
from olostep import ( # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
|
||||||
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
||||||
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return ToolResult.error(
|
return ToolResult.error(
|
||||||
|
|||||||
+78
-12
@@ -18,6 +18,7 @@ from typing import Any, cast
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import yaml
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
@@ -27,6 +28,7 @@ from nanobot.security.workspace_policy import is_path_within
|
|||||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
CLI_ANYTHING_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_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
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_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"
|
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||||
_CATALOG_SOURCES = (
|
_CATALOG_SOURCES = (
|
||||||
@@ -41,6 +43,8 @@ _MAX_ARTIFACT_REPORT = 12
|
|||||||
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
|
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
|
||||||
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", 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 = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
||||||
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
|
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
|
||||||
_ARTIFACT_EXTENSIONS = frozenset({
|
_ARTIFACT_EXTENSIONS = frozenset({
|
||||||
@@ -211,10 +215,21 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
|
|
||||||
def _safe_skill_name(name: str) -> str:
|
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("-")
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
return f"cli-app-{clean or 'app'}"
|
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:
|
def _has_shell_meta(command: str) -> bool:
|
||||||
return any(char in command for char in _SHELL_META_CHARS)
|
return any(char in command for char in _SHELL_META_CHARS)
|
||||||
|
|
||||||
@@ -613,7 +628,7 @@ class CliAppManager:
|
|||||||
"name": installed_name,
|
"name": installed_name,
|
||||||
"entry_point": entry_point,
|
"entry_point": entry_point,
|
||||||
"source": str(data.get("source") or ""),
|
"source": str(data.get("source") or ""),
|
||||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
"skill": self.skill_relative_path(installed_name),
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -640,7 +655,20 @@ class CliAppManager:
|
|||||||
return not _has_shell_meta(install_cmd)
|
return not _has_shell_meta(install_cmd)
|
||||||
|
|
||||||
def _skill_path(self, name: str) -> Path:
|
def _skill_path(self, name: str) -> Path:
|
||||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
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()
|
||||||
|
|
||||||
def _app_payload(
|
def _app_payload(
|
||||||
self,
|
self,
|
||||||
@@ -677,7 +705,7 @@ class CliAppManager:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"logo_url": logo_url,
|
"logo_url": logo_url,
|
||||||
"brand_color": brand_color,
|
"brand_color": brand_color,
|
||||||
"skill_installed": self._skill_path(name).is_file(),
|
"skill_installed": self._installed_skill_path(name).is_file(),
|
||||||
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +741,8 @@ class CliAppManager:
|
|||||||
name = str(app["name"])
|
name = str(app["name"])
|
||||||
entry_point = str(app.get("entry_point") or "")
|
entry_point = str(app.get("entry_point") or "")
|
||||||
strategy = self._strategy(app)
|
strategy = self._strategy(app)
|
||||||
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
|
skill_path = _plugin_skill_relative_path(name)
|
||||||
|
plugin_path = f"plugins/{_safe_skill_name(name)}"
|
||||||
capabilities = [
|
capabilities = [
|
||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -726,13 +755,13 @@ class CliAppManager:
|
|||||||
install = compact_dict({
|
install = compact_dict({
|
||||||
"supported": install_supported,
|
"supported": install_supported,
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": ["entry_point_available"] if entry_point else [],
|
"verification": ["entry_point_available"] if entry_point else [],
|
||||||
})
|
})
|
||||||
remove = compact_dict({
|
remove = compact_dict({
|
||||||
"supported": strategy != "unsupported",
|
"supported": strategy != "unsupported",
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": (
|
"verification": (
|
||||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||||
if strategy not in {"bundled", "unsupported"}
|
if strategy not in {"bundled", "unsupported"}
|
||||||
@@ -1032,11 +1061,10 @@ class CliAppManager:
|
|||||||
name = str(app.get("name") or "unknown")
|
name = str(app.get("name") or "unknown")
|
||||||
display = str(app.get("display_name") or name)
|
display = str(app.get("display_name") or name)
|
||||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||||
description = _catalog_description(app) or f"Use {display} from nanobot."
|
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||||
return f"""---
|
return f"""---
|
||||||
name: {_safe_skill_name(name)}
|
name: {_safe_skill_name(name)}
|
||||||
description: >-
|
description: {json.dumps(description, ensure_ascii=False)}
|
||||||
{description}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# {display}
|
# {display}
|
||||||
@@ -1072,25 +1100,63 @@ 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 "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
|
||||||
return note + "\n" + content
|
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:
|
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||||
path = self._skill_path(str(app["name"]))
|
path = self._skill_path(str(app["name"]))
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
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)
|
content = self._with_nanobot_skill_note(content, app)
|
||||||
path.write_text(content, encoding="utf-8")
|
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
|
return path
|
||||||
|
|
||||||
def remove_skill(self, name: str) -> None:
|
def remove_skill(self, name: str) -> None:
|
||||||
skill_dir = self._skill_path(name).parent
|
plugin_root = self._skill_path(name).parents[2]
|
||||||
if skill_dir.is_dir():
|
if plugin_root.is_dir():
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(plugin_root)
|
||||||
|
legacy_dir = self._legacy_skill_path(name).parent
|
||||||
|
if legacy_dir.is_dir():
|
||||||
|
shutil.rmtree(legacy_dir)
|
||||||
|
|
||||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
from nanobot.agent.plugins import set_agent_plugin_enabled
|
||||||
|
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
entry = self._installed_entry(app)
|
entry = self._installed_entry(app)
|
||||||
installed[str(app["name"])] = entry
|
installed[str(app["name"])] = entry
|
||||||
self._save_installed(installed)
|
self._save_installed(installed)
|
||||||
self.install_skill(app)
|
self.install_skill(app)
|
||||||
|
set_agent_plugin_enabled(self.workspace, _safe_skill_name(str(app["name"])), True)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def install(self, name: str) -> dict[str, Any]:
|
def install(self, name: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ def runtime_lines_for_request(
|
|||||||
"""Return CLI App annotations from an immutable request snapshot."""
|
"""Return CLI App annotations from an immutable request snapshot."""
|
||||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
if isinstance(structured, list):
|
if isinstance(structured, list):
|
||||||
|
from nanobot.apps.cli import CliAppManager
|
||||||
|
|
||||||
|
manager = CliAppManager(workspace=workspace)
|
||||||
structured_items = cast(list[Any], structured)
|
structured_items = cast(list[Any], structured)
|
||||||
mentions = [
|
mentions = [
|
||||||
cast(Mapping[str, Any], item) for item in structured_items
|
cast(Mapping[str, Any], item) for item in structured_items
|
||||||
@@ -32,7 +35,7 @@ def runtime_lines_for_request(
|
|||||||
f"@{str(item['name']).strip().lower()} "
|
f"@{str(item['name']).strip().lower()} "
|
||||||
f"(installed; tool=run_cli_app; "
|
f"(installed; tool=run_cli_app; "
|
||||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
f"skill={manager.skill_relative_path(str(item['name']))}). "
|
||||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
for item in mentions
|
for item in mentions
|
||||||
if str(item.get("name") or "").strip()
|
if str(item.get("name") or "").strip()
|
||||||
|
|||||||
@@ -238,20 +238,6 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_removal_when_both_ids_missing(self):
|
|
||||||
ch = _make_channel()
|
|
||||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
|
||||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
|
||||||
)
|
|
||||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
|
||||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
|
||||||
ch._remove_reaction = AsyncMock()
|
|
||||||
|
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
|
||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_removal_when_not_stream_end(self):
|
async def test_no_removal_when_not_stream_end(self):
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class WeixinConnectSession:
|
|||||||
channel: WeixinChannel
|
channel: WeixinChannel
|
||||||
current_poll_base_url: str
|
current_poll_base_url: str
|
||||||
refresh_count: int
|
refresh_count: int
|
||||||
|
force: bool
|
||||||
created_wall: float
|
created_wall: float
|
||||||
deadline: float
|
deadline: float
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
@@ -72,7 +73,7 @@ class WeixinConnectStore:
|
|||||||
|
|
||||||
channel.connect_open_client()
|
channel.connect_open_client()
|
||||||
try:
|
try:
|
||||||
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
|
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await self._close_channel(channel)
|
await self._close_channel(channel)
|
||||||
raise ChannelConnectError(
|
raise ChannelConnectError(
|
||||||
@@ -89,6 +90,7 @@ class WeixinConnectStore:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
current_poll_base_url=channel.connect_base_url,
|
current_poll_base_url=channel.connect_base_url,
|
||||||
refresh_count=0,
|
refresh_count=0,
|
||||||
|
force=force,
|
||||||
created_wall=now_wall,
|
created_wall=now_wall,
|
||||||
deadline=time.monotonic() + 600,
|
deadline=time.monotonic() + 600,
|
||||||
)
|
)
|
||||||
@@ -187,7 +189,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
session.qrcode_id, session.qr_url = (
|
||||||
await session.channel.connect_fetch_qr_code()
|
await session.channel.connect_fetch_qr_code(force=session.force)
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
@@ -204,6 +206,17 @@ class WeixinConnectStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if status == "binded_redirect":
|
if status == "binded_redirect":
|
||||||
|
if session.force:
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
await self._close_channel(session.channel)
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"status": "failed",
|
||||||
|
"message": (
|
||||||
|
"Unable to complete a new WeChat login. "
|
||||||
|
"Start again and scan with the account you want to connect."
|
||||||
|
),
|
||||||
|
}
|
||||||
if not session.channel.connect_load_state():
|
if not session.channel.connect_load_state():
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
await self._close_channel(session.channel)
|
await self._close_channel(session.channel)
|
||||||
@@ -234,7 +247,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
session.qrcode_id, session.qr_url = (
|
||||||
await session.channel.connect_fetch_qr_code()
|
await session.channel.connect_fetch_qr_code(force=session.force)
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
|
|||||||
@@ -726,9 +726,9 @@ class WeixinChannel(BaseChannel):
|
|||||||
break
|
break
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
async def _fetch_qr_code(self) -> tuple[str, str]:
|
async def _fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||||
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
|
"""Fetch a QR code without existing credentials when forced."""
|
||||||
local_tokens = self._local_token_list()
|
local_tokens = [] if force else self._local_token_list()
|
||||||
data = await self._api_post(
|
data = await self._api_post(
|
||||||
"ilink/bot/get_bot_qrcode?bot_type=3",
|
"ilink/bot/get_bot_qrcode?bot_type=3",
|
||||||
{"local_token_list": local_tokens},
|
{"local_token_list": local_tokens},
|
||||||
@@ -755,11 +755,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
||||||
return qrcode_id, (qrcode_img_content or qrcode_id)
|
return qrcode_id, (qrcode_img_content or qrcode_id)
|
||||||
|
|
||||||
async def _qr_login(self) -> bool:
|
async def _qr_login(self, *, force: bool = False) -> bool:
|
||||||
"""Perform QR code login flow. Returns True on success."""
|
"""Perform QR login; forced flows accept only newly confirmed credentials."""
|
||||||
try:
|
try:
|
||||||
refresh_count = 0
|
refresh_count = 0
|
||||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||||
self._print_qr_code(scan_url)
|
self._print_qr_code(scan_url)
|
||||||
current_poll_base_url = self.config.base_url
|
current_poll_base_url = self.config.base_url
|
||||||
verify_code = ""
|
verify_code = ""
|
||||||
@@ -825,11 +825,16 @@ class WeixinChannel(BaseChannel):
|
|||||||
if refresh_count > MAX_QR_REFRESH_COUNT:
|
if refresh_count > MAX_QR_REFRESH_COUNT:
|
||||||
self.logger.warning("WeChat verification failed too many times")
|
self.logger.warning("WeChat verification failed too many times")
|
||||||
return False
|
return False
|
||||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||||
current_poll_base_url = self.config.base_url
|
current_poll_base_url = self.config.base_url
|
||||||
self._print_qr_code(scan_url)
|
self._print_qr_code(scan_url)
|
||||||
continue
|
continue
|
||||||
elif status == "binded_redirect":
|
elif status == "binded_redirect":
|
||||||
|
if force:
|
||||||
|
self.logger.error(
|
||||||
|
"Forced WeChat login returned an existing binding without new credentials"
|
||||||
|
)
|
||||||
|
return False
|
||||||
if self._token or self._load_state():
|
if self._token or self._load_state():
|
||||||
self.logger.info("WeChat account is already connected")
|
self.logger.info("WeChat account is already connected")
|
||||||
return True
|
return True
|
||||||
@@ -846,7 +851,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
MAX_QR_REFRESH_COUNT,
|
MAX_QR_REFRESH_COUNT,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
qrcode_id, scan_url = await self._fetch_qr_code()
|
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
||||||
current_poll_base_url = self.config.base_url
|
current_poll_base_url = self.config.base_url
|
||||||
verify_code = ""
|
verify_code = ""
|
||||||
self._print_qr_code(scan_url)
|
self._print_qr_code(scan_url)
|
||||||
@@ -893,8 +898,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
async def connect_fetch_qr_code(self) -> tuple[str, str]:
|
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||||
return await self._fetch_qr_code()
|
return await self._fetch_qr_code(force=force)
|
||||||
|
|
||||||
async def connect_poll_qr_code(
|
async def connect_poll_qr_code(
|
||||||
self,
|
self,
|
||||||
@@ -947,14 +952,14 @@ class WeixinChannel(BaseChannel):
|
|||||||
if force:
|
if force:
|
||||||
self._token = ""
|
self._token = ""
|
||||||
self._get_updates_buf = ""
|
self._get_updates_buf = ""
|
||||||
if self._token or self._load_state():
|
if self._token or (not force and self._load_state()):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Initialize HTTP client for the login flow
|
# Initialize HTTP client for the login flow
|
||||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||||
self._running = True # Enable polling loop in _qr_login()
|
self._running = True # Enable polling loop in _qr_login()
|
||||||
try:
|
try:
|
||||||
return await self._qr_login()
|
return await self._qr_login(force=force)
|
||||||
finally:
|
finally:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._client:
|
if self._client:
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
async def fake_fetch_qr_code(
|
||||||
|
self: WeixinChannel, **_kwargs: Any
|
||||||
|
) -> tuple[str, str]:
|
||||||
return "qr-1", "https://qr.example/1"
|
return "qr-1", "https://qr.example/1"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -86,14 +88,31 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
observed_force: list[bool] = []
|
||||||
return "qr-reconnect", "https://qr.example/reconnect"
|
|
||||||
|
async def fake_fetch_qr_code(
|
||||||
|
self: WeixinChannel,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
observed_force.append(force)
|
||||||
|
return f"qr-reconnect-{len(observed_force)}", "https://qr.example/reconnect"
|
||||||
|
|
||||||
|
async def fake_api_get_with_base(
|
||||||
|
self: WeixinChannel,
|
||||||
|
**_kwargs: Any,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
return {"status": "expired"}
|
||||||
|
|
||||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||||
|
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=True)
|
started = await store.start(force=True)
|
||||||
|
refreshed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
|
assert refreshed["status"] == "pending"
|
||||||
|
assert observed_force == [True, True]
|
||||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||||
cancelled = await store.cancel(started["session_id"])
|
cancelled = await store.cancel(started["session_id"])
|
||||||
assert cancelled["status"] == "cancelled"
|
assert cancelled["status"] == "cancelled"
|
||||||
@@ -116,7 +135,9 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
|||||||
poll_started = asyncio.Event()
|
poll_started = asyncio.Event()
|
||||||
release_poll = asyncio.Event()
|
release_poll = asyncio.Event()
|
||||||
|
|
||||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
async def fake_fetch_qr_code(
|
||||||
|
self: WeixinChannel, **_kwargs: Any
|
||||||
|
) -> tuple[str, str]:
|
||||||
return "qr-cancel", "https://qr.example/cancel"
|
return "qr-cancel", "https://qr.example/cancel"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -162,7 +183,9 @@ async def test_weixin_connect_store_handles_verification_code(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
async def fake_fetch_qr_code(
|
||||||
|
self: WeixinChannel, **_kwargs: Any
|
||||||
|
) -> tuple[str, str]:
|
||||||
return "qr-verify", "https://qr.example/verify"
|
return "qr-verify", "https://qr.example/verify"
|
||||||
|
|
||||||
responses = [
|
responses = [
|
||||||
@@ -204,7 +227,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_weixin_connect_store_treats_existing_binding_as_success(
|
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -221,7 +244,12 @@ async def test_weixin_connect_store_treats_existing_binding_as_success(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
async def fake_fetch_qr_code(
|
||||||
|
self: WeixinChannel,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
assert force is True
|
||||||
return "qr-existing", "https://qr.example/existing"
|
return "qr-existing", "https://qr.example/existing"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -237,8 +265,8 @@ async def test_weixin_connect_store_treats_existing_binding_as_success(
|
|||||||
started = await store.start(force=True)
|
started = await store.start(force=True)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "succeeded"
|
assert completed["status"] == "failed"
|
||||||
assert "already connected" in completed["message"]
|
assert "new WeChat login" in completed["message"]
|
||||||
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
||||||
|
|
||||||
|
|
||||||
@@ -255,7 +283,9 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
async def fake_fetch_qr_code(
|
||||||
|
self: WeixinChannel, **_kwargs: Any
|
||||||
|
) -> tuple[str, str]:
|
||||||
return "qr-missing", "https://qr.example/missing"
|
return "qr-missing", "https://qr.example/missing"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -268,7 +298,7 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
|
|||||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=True)
|
started = await store.start(force=False)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "failed"
|
assert completed["status"] == "failed"
|
||||||
|
|||||||
@@ -196,6 +196,86 @@ def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_pat
|
|||||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_force_ignores_persisted_account_through_qr_flow(tmp_path) -> None:
|
||||||
|
persisted = {
|
||||||
|
"token": "persisted-token",
|
||||||
|
"get_updates_buf": "persisted-cursor",
|
||||||
|
"context_tokens": {"wx-user": "ctx-persisted"},
|
||||||
|
"typing_tickets": {"wx-user": {"ticket": "ticket-persisted"}},
|
||||||
|
"base_url": "https://persisted.example",
|
||||||
|
}
|
||||||
|
channel = WeixinChannel(
|
||||||
|
WeixinConfig(
|
||||||
|
enabled=True,
|
||||||
|
allow_from=["*"],
|
||||||
|
token="configured-token",
|
||||||
|
state_dir=str(tmp_path),
|
||||||
|
),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
(tmp_path / "account.json").write_text(
|
||||||
|
json.dumps(persisted),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
channel._print_qr_code = lambda _url: None
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||||
|
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
channel._api_get_with_base = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"status": "expired"},
|
||||||
|
{"status": "binded_redirect"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = await channel.login(force=True)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert [call.args[1]["local_token_list"] for call in channel._api_post.await_args_list] == [
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
assert channel._token == ""
|
||||||
|
assert channel._get_updates_buf == ""
|
||||||
|
assert channel._context_tokens == {}
|
||||||
|
assert channel._typing_tickets == {}
|
||||||
|
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
||||||
|
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_without_force_reuses_persisted_account(tmp_path) -> None:
|
||||||
|
channel = WeixinChannel(
|
||||||
|
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
(tmp_path / "account.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"token": "persisted-token",
|
||||||
|
"get_updates_buf": "persisted-cursor",
|
||||||
|
"context_tokens": {"wx-user": "ctx-persisted"},
|
||||||
|
"base_url": "https://persisted.example",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
channel._qr_login = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
ok = await channel.login(force=False)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
channel._qr_login.assert_not_awaited()
|
||||||
|
assert channel._token == "persisted-token"
|
||||||
|
assert channel._get_updates_buf == "persisted-cursor"
|
||||||
|
assert channel._context_tokens == {"wx-user": "ctx-persisted"}
|
||||||
|
assert channel.config.base_url == "https://persisted.example"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||||
channel, bus = _make_channel()
|
channel, bus = _make_channel()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -16,6 +17,11 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, Mapping, cast
|
from typing import Any, Literal, Mapping, cast
|
||||||
|
|
||||||
|
from nanobot.agent.plugins import (
|
||||||
|
AgentPluginState,
|
||||||
|
discover_agent_plugin_states,
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
)
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||||
@@ -48,6 +54,12 @@ _MAX_TEST_TOOLS = 16
|
|||||||
_DEFAULT_TEST_TIMEOUT = 20
|
_DEFAULT_TEST_TIMEOUT = 20
|
||||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||||
|
_PLUGIN_LOGO_MIME_TYPES = {
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||||
|
|
||||||
@@ -837,6 +849,51 @@ def _custom_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_logo_data_url(path: Path | None) -> str | None:
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
valid = (
|
||||||
|
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
|
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
||||||
|
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
||||||
|
)
|
||||||
|
if not valid:
|
||||||
|
return None
|
||||||
|
encoded = base64.b64encode(data).decode("ascii")
|
||||||
|
return f"data:{_PLUGIN_LOGO_MIME_TYPES[suffix]};base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
|
||||||
|
plugin = state.plugin
|
||||||
|
return {
|
||||||
|
"name": f"plugin-{plugin.name}",
|
||||||
|
"display_name": plugin.display_name,
|
||||||
|
"category": plugin.category,
|
||||||
|
"description": plugin.description or "Agent Plugin",
|
||||||
|
"docs_url": plugin.repository,
|
||||||
|
"transport": "stdio",
|
||||||
|
"requires": ", ".join(plugin.permissions),
|
||||||
|
"note": "",
|
||||||
|
"install_supported": True,
|
||||||
|
"installed": True,
|
||||||
|
"configured": state.enabled,
|
||||||
|
"available": state.enabled,
|
||||||
|
"status": "configured" if state.enabled else "not_installed",
|
||||||
|
"logo_url": _plugin_logo_data_url(plugin.logo),
|
||||||
|
"brand_color": plugin.accent_color,
|
||||||
|
"required_fields": [],
|
||||||
|
"connection_summary": ", ".join(state.mcp_servers),
|
||||||
|
"enabled_tools": ["*"],
|
||||||
|
"tool_names": [],
|
||||||
|
"source": "agent-plugin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def mcp_presets_payload(
|
def mcp_presets_payload(
|
||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
@@ -854,9 +911,21 @@ def mcp_presets_payload(
|
|||||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||||
if name not in known
|
if name not in known
|
||||||
]
|
]
|
||||||
|
plugin_states = [
|
||||||
|
state
|
||||||
|
for state in discover_agent_plugin_states(config.workspace_path)
|
||||||
|
if state.mcp_servers or state.plugin.install_command
|
||||||
|
]
|
||||||
|
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||||
|
plugin_rows = [
|
||||||
|
row
|
||||||
|
for state in plugin_states
|
||||||
|
if (row := _agent_plugin_payload(state))["name"] not in existing_names
|
||||||
|
]
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"presets": [*preset_rows, *custom_rows],
|
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||||
"installed_count": len(config.tools.mcp_servers),
|
"installed_count": len(config.tools.mcp_servers)
|
||||||
|
+ sum(int(row["configured"]) for row in plugin_rows),
|
||||||
}
|
}
|
||||||
if last_action is not None:
|
if last_action is not None:
|
||||||
payload["last_action"] = last_action
|
payload["last_action"] = last_action
|
||||||
@@ -1343,6 +1412,31 @@ async def mcp_presets_settings_action(
|
|||||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||||
if action is None:
|
if action is None:
|
||||||
return mcp_presets_payload()
|
return mcp_presets_payload()
|
||||||
|
name = (_query_first(query, "name") or "").strip()
|
||||||
|
if name.startswith("plugin-"):
|
||||||
|
config = load_config()
|
||||||
|
plugin_name = name.removeprefix("plugin-")
|
||||||
|
installed = {
|
||||||
|
state.plugin.name
|
||||||
|
for state in discover_agent_plugin_states(config.workspace_path)
|
||||||
|
if state.mcp_servers or state.plugin.install_command
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
plugin = await asyncio.to_thread(
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
config.workspace_path,
|
||||||
|
plugin_name,
|
||||||
|
action == "enable",
|
||||||
|
)
|
||||||
|
verb = "enabled" if action == "enable" else "disabled"
|
||||||
|
payload = mcp_presets_payload(
|
||||||
|
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."}
|
||||||
|
)
|
||||||
|
if reload_mcp is not None:
|
||||||
|
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||||
|
return payload
|
||||||
if action == "test":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query)
|
return await mcp_presets_test_action(query)
|
||||||
if action in _CUSTOM_ACTIONS:
|
if action in _CUSTOM_ACTIONS:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from urllib.parse import unquote
|
|||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
|
from nanobot.agent.plugins import discover_agent_plugin_states
|
||||||
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
||||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
||||||
@@ -222,12 +223,12 @@ class WebUISettingsRouter:
|
|||||||
if path == "/api/settings/pairing/deny":
|
if path == "/api/settings/pairing/deny":
|
||||||
return self._handle_settings_pairing_action(request, "deny")
|
return self._handle_settings_pairing_action(request, "deny")
|
||||||
if path == "/api/settings/mcp-presets":
|
if path == "/api/settings/mcp-presets":
|
||||||
return await self._handle_settings_mcp_presets(request)
|
return await self._handle_settings_mcp_presets(connection, request)
|
||||||
if path == "/api/settings/version-check":
|
if path == "/api/settings/version-check":
|
||||||
return await self._handle_settings_version_check(request)
|
return await self._handle_settings_version_check(request)
|
||||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
||||||
if mcp_action is not None:
|
if mcp_action is not None:
|
||||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
return await self._handle_settings_mcp_presets(connection, request, mcp_action)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _query(self, request: WsRequest) -> QueryParams:
|
def _query(self, request: WsRequest) -> QueryParams:
|
||||||
@@ -1144,15 +1145,34 @@ class WebUISettingsRouter:
|
|||||||
|
|
||||||
async def _handle_settings_mcp_presets(
|
async def _handle_settings_mcp_presets(
|
||||||
self,
|
self,
|
||||||
|
connection: Any,
|
||||||
request: WsRequest,
|
request: WsRequest,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
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-{state.plugin.name}"
|
||||||
|
for state in discover_agent_plugin_states(config.workspace_path)
|
||||||
|
if state.mcp_servers or state.plugin.install_command
|
||||||
|
}
|
||||||
|
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(
|
payload = await mcp_presets_settings_action(
|
||||||
action,
|
action,
|
||||||
self._parse_mcp_settings_query(request),
|
query,
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
|
|
||||||
@@ -42,7 +43,13 @@ def check_for_update() -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
_cache = (now, latest)
|
_cache = (now, latest)
|
||||||
|
|
||||||
if not latest or latest == __version__:
|
if not isinstance(latest, str) or not latest:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if Version(latest) <= Version(__version__):
|
||||||
|
return None
|
||||||
|
except InvalidVersion:
|
||||||
|
logger.debug("PyPI returned an invalid nanobot version: %r", latest)
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"currentVersion": __version__,
|
"currentVersion": __version__,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dependencies = [
|
|||||||
"readability-lxml>=0.8.4,<1.0.0",
|
"readability-lxml>=0.8.4,<1.0.0",
|
||||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||||
"rich>=14.0.0,<15.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
|
"qrcode[pil]>=8.0",
|
||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Barrier
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent import plugins as agent_plugins
|
||||||
|
from nanobot.agent.plugins import (
|
||||||
|
AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
AGENT_PLUGIN_SCHEMA,
|
||||||
|
agent_plugin_mcp_servers,
|
||||||
|
discover_agent_plugin_skills,
|
||||||
|
discover_agent_plugin_states,
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
)
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agent_plugins,
|
||||||
|
"get_config_path",
|
||||||
|
lambda: tmp_path / "config" / "config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_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.")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
|
||||||
|
|
||||||
|
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")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_plugin_discovers_contained_raster_logo(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(
|
||||||
|
tmp_path,
|
||||||
|
"demo",
|
||||||
|
manifest={
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": "demo",
|
||||||
|
"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assets = plugin / "assets"
|
||||||
|
assets.mkdir()
|
||||||
|
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||||
|
assert agent_plugins.discover_agent_plugins(tmp_path)[0].logo == assets / "icon.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_plugin_logo_cannot_escape_package(tmp_path: Path) -> None:
|
||||||
|
outside = tmp_path / "outside.png"
|
||||||
|
outside.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||||
|
plugin = _write_plugin(
|
||||||
|
tmp_path,
|
||||||
|
"demo",
|
||||||
|
manifest={
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": "demo",
|
||||||
|
"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assets = plugin / "assets"
|
||||||
|
assets.mkdir()
|
||||||
|
try:
|
||||||
|
(assets / "icon.png").symlink_to(outside)
|
||||||
|
except OSError as exc:
|
||||||
|
pytest.skip(f"file symlink unavailable: {exc}")
|
||||||
|
|
||||||
|
assert agent_plugins.discover_agent_plugins(tmp_path)[0].logo is None
|
||||||
|
|
||||||
|
|
||||||
|
@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.")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||||
|
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_disabled_plugin_skill_cannot_shadow_or_inject_builtin_skill(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo")
|
||||||
|
skill = _write_skill(plugin, "shared", description="Plugin version.")
|
||||||
|
(skill / "SKILL.md").write_text(
|
||||||
|
"---\nname: shared\ndescription: Plugin version.\nalways: true\n---\n\nPlugin body.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
builtin = tmp_path / "builtin"
|
||||||
|
builtin_skill = builtin / "shared"
|
||||||
|
builtin_skill.mkdir(parents=True)
|
||||||
|
(builtin_skill / "SKILL.md").write_text(
|
||||||
|
"---\nname: shared\ndescription: Built-in version.\n---\n\nBuilt-in body.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
loader = SkillsLoader(tmp_path, builtin_skills_dir=builtin)
|
||||||
|
|
||||||
|
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||||
|
assert "Built-in version" in (loader.load_skill("shared") or "")
|
||||||
|
assert loader.get_always_skills() == []
|
||||||
|
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||||
|
assert [entry["source"] for entry in loader.list_skills()] == ["plugin"]
|
||||||
|
assert "Plugin body" in (loader.load_skill("shared") or "")
|
||||||
|
assert loader.get_always_skills() == ["shared"]
|
||||||
|
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", False)
|
||||||
|
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||||
|
assert "Built-in 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) -> None:
|
||||||
|
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.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 discover_agent_plugin_states(tmp_path)[0].setup_required is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_plugin_enable_runs_setup_once(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
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[str, ...]] = []
|
||||||
|
|
||||||
|
def run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]:
|
||||||
|
calls.append(command)
|
||||||
|
time.sleep(0.1)
|
||||||
|
return subprocess.CompletedProcess(command, 0, "ok", "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
|
||||||
|
ready = Barrier(2)
|
||||||
|
|
||||||
|
def enable() -> None:
|
||||||
|
ready.wait()
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
futures = [executor.submit(enable) for _ in range(2)]
|
||||||
|
for future in futures:
|
||||||
|
future.result()
|
||||||
|
|
||||||
|
assert calls == [(str(executable),)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_plugin_mcp_entries_do_not_block_valid_servers(tmp_path: Path) -> None:
|
||||||
|
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)
|
||||||
@@ -84,10 +84,6 @@ class TestToolHintKnownTools:
|
|||||||
assert '"C:/Program Files/Git/project"' not in result
|
assert '"C:/Program Files/Git/project"' not in result
|
||||||
assert '"' in result
|
assert '"' in result
|
||||||
|
|
||||||
def test_exec_short_command_unchanged(self):
|
|
||||||
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
|
||||||
assert result == "$ npm install typescript"
|
|
||||||
|
|
||||||
def test_exec_chained_commands_truncated_not_mid_path(self):
|
def test_exec_chained_commands_truncated_not_mid_path(self):
|
||||||
"""Long chained commands should truncate preserving abbreviated paths."""
|
"""Long chained commands should truncate preserving abbreviated paths."""
|
||||||
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
||||||
|
|||||||
@@ -241,24 +241,6 @@ async def test_file_edit_events_route_to_channel_capability(manager):
|
|||||||
channel._send_mock.assert_not_awaited()
|
channel._send_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_typed_file_edit_event_routes_to_channel_capability(manager):
|
|
||||||
channel = manager.channels["mock"]
|
|
||||||
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
|
|
||||||
msg = outbound_message_for_event(
|
|
||||||
channel="mock",
|
|
||||||
chat_id="c1",
|
|
||||||
event=ProgressEvent(file_edit_events=edits),
|
|
||||||
)
|
|
||||||
|
|
||||||
await manager._send_once(channel, msg)
|
|
||||||
|
|
||||||
channel._file_edit_mock.assert_awaited_once_with(
|
|
||||||
"c1", edits, msg.metadata
|
|
||||||
)
|
|
||||||
channel._send_mock.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_base_channel_file_edit_events_are_noop_safe():
|
async def test_base_channel_file_edit_events_are_noop_safe():
|
||||||
class _Plain(BaseChannel):
|
class _Plain(BaseChannel):
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.bus.outbound_events import (
|
||||||
|
StreamDeltaEvent,
|
||||||
|
StreamedResponseEvent,
|
||||||
|
StreamEndEvent,
|
||||||
|
outbound_message_for_event,
|
||||||
|
)
|
||||||
|
from nanobot.cli.commands import app
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("streamed", [False, True])
|
||||||
|
def test_interactive_agent_routes_a_complete_user_turn(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path,
|
||||||
|
streamed: bool,
|
||||||
|
) -> None:
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
||||||
|
seen: dict[str, object] = {}
|
||||||
|
renderers: list[object] = []
|
||||||
|
|
||||||
|
class _Renderer:
|
||||||
|
def __init__(self, **_kwargs: object) -> None:
|
||||||
|
self.streamed = False
|
||||||
|
self.header_printed = False
|
||||||
|
self.deltas: list[str] = []
|
||||||
|
self.ends: list[bool] = []
|
||||||
|
self.closed = 0
|
||||||
|
renderers.append(self)
|
||||||
|
|
||||||
|
async def on_delta(self, content: str) -> None:
|
||||||
|
self.streamed = True
|
||||||
|
self.deltas.append(content)
|
||||||
|
|
||||||
|
async def on_end(self, *, resuming: bool = False) -> None:
|
||||||
|
self.ends.append(resuming)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed += 1
|
||||||
|
|
||||||
|
def stop_for_input(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _AgentLoop:
|
||||||
|
channels_config = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, _config, bus, **_kwargs):
|
||||||
|
instance = cls(bus)
|
||||||
|
seen["loop"] = instance
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def __init__(self, bus) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
self.stopped = asyncio.Event()
|
||||||
|
self.close_mcp_calls = 0
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
message = await self.bus.consume_inbound()
|
||||||
|
seen["inbound"] = message
|
||||||
|
if streamed:
|
||||||
|
for event in (
|
||||||
|
StreamDeltaEvent(content="hello "),
|
||||||
|
StreamDeltaEvent(content="world"),
|
||||||
|
StreamEndEvent(),
|
||||||
|
StreamedResponseEvent(),
|
||||||
|
):
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel=message.channel,
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
event=event,
|
||||||
|
content="hello world" if isinstance(event, StreamedResponseEvent) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.bus.publish_outbound(
|
||||||
|
OutboundMessage(
|
||||||
|
channel=message.channel,
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
content="hello world",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.stopped.wait()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.stopped.set()
|
||||||
|
|
||||||
|
async def close_mcp(self) -> None:
|
||||||
|
self.close_mcp_calls += 1
|
||||||
|
|
||||||
|
read_input = AsyncMock(side_effect=["hello nanobot", "exit"])
|
||||||
|
print_response = MagicMock()
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: config)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda *_args: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.is_default_workspace", lambda *_args: False)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent._set_nanobot_logs", lambda *_args: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent._model_display", lambda *_args: ("test-model", ""))
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.consume_restart_notice_from_env", lambda: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _AgentLoop)
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.StreamRenderer", _Renderer)
|
||||||
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda *_args: object())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.image_generation.image_gen_provider_configs",
|
||||||
|
lambda *_args: [],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", lambda *_args: object())
|
||||||
|
monkeypatch.setattr("nanobot.cli.agent.signal.signal", lambda *_args: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._init_prompt_session", lambda: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._flush_pending_tty_input", lambda: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._restore_terminal", lambda: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._read_interactive_input_async", read_input)
|
||||||
|
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", print_response)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["agent", "--session", "cli:journey"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
inbound = seen["inbound"]
|
||||||
|
assert isinstance(inbound, InboundMessage)
|
||||||
|
assert (inbound.channel, inbound.chat_id, inbound.content) == (
|
||||||
|
"cli",
|
||||||
|
"journey",
|
||||||
|
"hello nanobot",
|
||||||
|
)
|
||||||
|
assert inbound.metadata == {"_wants_stream": True}
|
||||||
|
loop = seen["loop"]
|
||||||
|
assert isinstance(loop, _AgentLoop)
|
||||||
|
assert loop.close_mcp_calls == 1
|
||||||
|
assert len(renderers) == 1
|
||||||
|
renderer = renderers[0]
|
||||||
|
assert isinstance(renderer, _Renderer)
|
||||||
|
if streamed:
|
||||||
|
assert renderer.deltas == ["hello ", "world"]
|
||||||
|
assert renderer.ends == [False]
|
||||||
|
assert renderer.closed == 0
|
||||||
|
print_response.assert_not_called()
|
||||||
|
else:
|
||||||
|
assert renderer.deltas == []
|
||||||
|
assert renderer.closed == 1
|
||||||
|
print_response.assert_called_once_with(
|
||||||
|
"hello world",
|
||||||
|
render_markdown=True,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
@@ -1231,27 +1231,6 @@ def test_openai_compat_provider_passes_model_through():
|
|||||||
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
|
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
|
||||||
|
|
||||||
|
|
||||||
def test_make_provider_uses_github_copilot_backend():
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.providers.factory import make_provider
|
|
||||||
|
|
||||||
config = Config.model_validate(
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"provider": "github-copilot",
|
|
||||||
"model": "github-copilot/gpt-4.1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = make_provider(config)
|
|
||||||
|
|
||||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_codex_proxy_config_affects_provider_and_signature():
|
def test_openai_codex_proxy_config_affects_provider_and_signature():
|
||||||
def config_with_proxy(proxy: str) -> Config:
|
def config_with_proxy(proxy: str) -> Config:
|
||||||
return Config.model_validate(
|
return Config.model_validate(
|
||||||
|
|||||||
@@ -9,9 +9,21 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent import plugins as agent_plugins
|
||||||
|
from nanobot.agent.plugins import discover_agent_plugin_skills
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agent_plugins,
|
||||||
|
"get_config_path",
|
||||||
|
lambda: tmp_path / "config" / "config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_cache(path: Path, registry: dict) -> None:
|
def _write_cache(path: Path, registry: dict) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(
|
path.write_text(
|
||||||
@@ -391,6 +403,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
"_fetch_skill_content",
|
"_fetch_skill_content",
|
||||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
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")
|
payload = manager.install("gimp")
|
||||||
|
|
||||||
@@ -400,9 +415,26 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
assert "state_recorded" in payload["last_action"]["verification"]
|
assert "state_recorded" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||||
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
plugin = manager.workspace / "plugins" / "cli-app-gimp"
|
||||||
|
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||||
assert skill.is_file()
|
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 '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 [
|
||||||
|
item["name"]
|
||||||
|
for item in SkillsLoader(manager.workspace).list_skills()
|
||||||
|
if item["source"] == "plugin"
|
||||||
|
] == ["cli-app-gimp"]
|
||||||
|
assert not legacy.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_run_argv_logs_command_exit_and_output(
|
def test_run_argv_logs_command_exit_and_output(
|
||||||
@@ -487,7 +519,14 @@ def test_install_records_available_cli_without_reinstalling(
|
|||||||
assert "entry_point_available" in payload["last_action"]["verification"]
|
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
||||||
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
|
skill = (
|
||||||
|
manager.workspace
|
||||||
|
/ "plugins"
|
||||||
|
/ "cli-app-feishu"
|
||||||
|
/ "skills"
|
||||||
|
/ "cli-app-feishu"
|
||||||
|
/ "SKILL.md"
|
||||||
|
)
|
||||||
assert skill.is_file()
|
assert skill.is_file()
|
||||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
||||||
|
|
||||||
@@ -704,7 +743,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||||
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
|
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
|
||||||
|
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
|
||||||
skill_dir.mkdir(parents=True)
|
skill_dir.mkdir(parents=True)
|
||||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -717,7 +757,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
|
|
||||||
assert payload["last_action"]["ok"] is True
|
assert payload["last_action"]["ok"] is True
|
||||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert not skill_dir.exists()
|
assert not plugin_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||||
@@ -845,19 +885,47 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
|
|||||||
"name": "zoom",
|
"name": "zoom",
|
||||||
"entry_point": "cli-anything-zoom",
|
"entry_point": "cli-anything-zoom",
|
||||||
"source": "public",
|
"source": "public",
|
||||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gimp",
|
"name": "gimp",
|
||||||
"entry_point": "cli-anything-gimp",
|
"entry_point": "cli-anything-gimp",
|
||||||
"source": "harness",
|
"source": "harness",
|
||||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"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:
|
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_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 "CLI App Mention: @zoom" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" in joined
|
assert "entry_point=cli-anything-zoom" in joined
|
||||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||||
|
|
||||||
|
|
||||||
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||||
@@ -58,4 +58,23 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
|||||||
assert "CLI App Attachment: @zoom" in joined
|
assert "CLI App Attachment: @zoom" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" in joined
|
assert "entry_point=cli-anything-zoom" in joined
|
||||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_cli_app_attachment_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)
|
||||||
|
|||||||
@@ -243,27 +243,6 @@ def test_workspace_override(tmp_path):
|
|||||||
assert bot._loop.workspace == custom_ws
|
assert bot._loop.workspace == custom_ws
|
||||||
|
|
||||||
|
|
||||||
def test_sdk_make_provider_uses_github_copilot_backend():
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.providers.factory import make_provider
|
|
||||||
|
|
||||||
config = Config.model_validate(
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"provider": "github-copilot",
|
|
||||||
"model": "github-copilot/gpt-4.1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = make_provider(config)
|
|
||||||
|
|
||||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_custom_session_key(tmp_path):
|
async def test_run_custom_session_key(tmp_path):
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import asyncio
|
|||||||
import socket
|
import socket
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools import mcp as mcp_mod
|
||||||
from nanobot.agent.tools.mcp import _probe_http_url, connect_mcp_servers
|
from nanobot.agent.tools.mcp import _probe_http_url, connect_mcp_servers
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
from nanobot.security.network import configure_ssrf_whitelist
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
||||||
@@ -171,6 +174,58 @@ async def test_connect_skips_unreachable_sse():
|
|||||||
assert len(registry._tools) == 0
|
assert len(registry._tools) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_isolates_streamable_http_status_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A reachable endpoint returning HTTP 530 must not poison the event loop."""
|
||||||
|
async def _reachable(_url: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _return_http_530(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(530, text="cloudflare error 1033", request=request)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
|
||||||
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_mod,
|
||||||
|
"PinnedDNSAsyncTransport",
|
||||||
|
lambda: httpx.MockTransport(_return_http_530),
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
previous_exception_handler = loop.get_exception_handler()
|
||||||
|
unhandled: list[BaseException] = []
|
||||||
|
|
||||||
|
def _capture_unhandled(_loop: asyncio.AbstractEventLoop, context: dict) -> None:
|
||||||
|
if isinstance(context.get("exception"), BaseException):
|
||||||
|
unhandled.append(context["exception"])
|
||||||
|
|
||||||
|
loop.set_exception_handler(_capture_unhandled)
|
||||||
|
try:
|
||||||
|
registry = ToolRegistry()
|
||||||
|
stacks = await asyncio.wait_for(
|
||||||
|
connect_mcp_servers(
|
||||||
|
{
|
||||||
|
"cloudflare": MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
url="https://mcp.example.com/mcp",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
),
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert stacks == {}
|
||||||
|
assert registry.tool_names == []
|
||||||
|
assert unhandled == []
|
||||||
|
assert not any(task.get_name() == "mcp:cloudflare" for task in asyncio.all_tasks())
|
||||||
|
finally:
|
||||||
|
loop.set_exception_handler(previous_exception_handler)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_probe_not_called_for_stdio():
|
async def test_probe_not_called_for_stdio():
|
||||||
"""stdio transport should not be probed — it spawns a local process."""
|
"""stdio transport should not be probed — it spawns a local process."""
|
||||||
|
|||||||
@@ -1082,10 +1082,22 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("failure_mode", ["exception", "cancellation"])
|
||||||
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
failure_mode: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
sessions = {"good": _make_fake_session(["demo"])}
|
bad_session = _make_fake_session([])
|
||||||
|
|
||||||
|
async def _cancel_initialize() -> None:
|
||||||
|
raise asyncio.CancelledError("cancelled by SDK")
|
||||||
|
|
||||||
|
if failure_mode == "cancellation":
|
||||||
|
bad_session.initialize = _cancel_initialize
|
||||||
|
sessions = {
|
||||||
|
"bad": bad_session,
|
||||||
|
"good": _make_fake_session(["demo"]),
|
||||||
|
}
|
||||||
|
|
||||||
class _SelectiveClientSession:
|
class _SelectiveClientSession:
|
||||||
def __init__(self, read: object, _write: object) -> None:
|
def __init__(self, read: object, _write: object) -> None:
|
||||||
@@ -1099,7 +1111,7 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _selective_stdio_client(params: object):
|
async def _selective_stdio_client(params: object):
|
||||||
if params.command == "bad":
|
if params.command == "bad" and failure_mode == "exception":
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
yield params.command, object()
|
yield params.command, object()
|
||||||
|
|
||||||
@@ -1109,8 +1121,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stacks = await connect_mcp_servers(
|
||||||
{
|
{
|
||||||
"good": MCPServerConfig(command="good"),
|
|
||||||
"bad": MCPServerConfig(command="bad"),
|
"bad": MCPServerConfig(command="bad"),
|
||||||
|
"good": MCPServerConfig(command="good"),
|
||||||
},
|
},
|
||||||
registry,
|
registry,
|
||||||
)
|
)
|
||||||
@@ -1121,6 +1133,36 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
assert set(stacks) == {"good"}
|
assert set(stacks) == {"good"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_mcp_servers_propagates_external_cancellation(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
started = asyncio.Event()
|
||||||
|
closed = asyncio.Event()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _blocking_stdio_client(_params: object):
|
||||||
|
try:
|
||||||
|
started.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
yield object(), object()
|
||||||
|
finally:
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _blocking_stdio_client)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
connect_mcp_servers({"slow": MCPServerConfig(command="slow")}, ToolRegistry())
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
await asyncio.wait_for(closed.wait(), timeout=1.0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import nanobot.webui.forking as forking
|
||||||
|
from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_fork_rebuilds_missing_transcript_and_saves_clean_title(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
forked = SimpleNamespace(messages=[{"role": "user", "content": "hello"}], metadata={})
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.fork_session_before_user_index.return_value = forked
|
||||||
|
rebuild = MagicMock()
|
||||||
|
marker = MagicMock()
|
||||||
|
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "fork-id")
|
||||||
|
monkeypatch.setattr(forking, "fork_transcript_before_user_index", lambda *_args: False)
|
||||||
|
monkeypatch.setattr(forking, "write_session_messages_as_transcript", rebuild)
|
||||||
|
monkeypatch.setattr(forking, "append_fork_marker", marker)
|
||||||
|
|
||||||
|
result = forking.create_webui_chat_fork(
|
||||||
|
manager,
|
||||||
|
source_chat_id="source",
|
||||||
|
before_user_index=2,
|
||||||
|
title=" Useful fork ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == ("fork-id", "websocket:fork-id")
|
||||||
|
manager.fork_session_before_user_index.assert_called_once_with(
|
||||||
|
"websocket:source",
|
||||||
|
"websocket:fork-id",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
rebuild.assert_called_once_with("websocket:fork-id", forked.messages)
|
||||||
|
marker.assert_called_once_with("websocket:fork-id")
|
||||||
|
assert forked.metadata[WEBUI_TITLE_METADATA_KEY] == "Useful fork"
|
||||||
|
manager.save.assert_called_once_with(forked, fsync=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_fork_rolls_back_session_and_transcript_together(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.fork_session_before_user_index.return_value = SimpleNamespace(
|
||||||
|
messages=[],
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
delete_transcript = MagicMock()
|
||||||
|
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "failed-fork")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
forking,
|
||||||
|
"fork_transcript_before_user_index",
|
||||||
|
MagicMock(side_effect=OSError("disk full")),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(forking, "delete_webui_transcript", delete_transcript)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="disk full"):
|
||||||
|
forking.create_webui_chat_fork(
|
||||||
|
manager,
|
||||||
|
source_chat_id="source",
|
||||||
|
before_user_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_transcript.assert_called_once_with("websocket:failed-fork")
|
||||||
|
manager.delete_session.assert_called_once_with("websocket:failed-fork")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_fork_stops_before_transcript_work_when_source_is_invalid(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.fork_session_before_user_index.return_value = None
|
||||||
|
fork_transcript = MagicMock()
|
||||||
|
monkeypatch.setattr(forking, "fork_transcript_before_user_index", fork_transcript)
|
||||||
|
|
||||||
|
result = forking.create_webui_chat_fork(
|
||||||
|
manager,
|
||||||
|
source_chat_id="missing",
|
||||||
|
before_user_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
fork_transcript.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("envelope", "detail"),
|
||||||
|
[
|
||||||
|
({"source_chat_id": "bad/id", "before_user_index": 0}, "invalid source_chat_id"),
|
||||||
|
({"source_chat_id": "source", "before_user_index": True}, "invalid before_user_index"),
|
||||||
|
({"source_chat_id": "source", "before_user_index": -1}, "invalid before_user_index"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_handler_rejects_invalid_protocol_input(
|
||||||
|
envelope: dict[str, object],
|
||||||
|
detail: str,
|
||||||
|
) -> None:
|
||||||
|
connection = object()
|
||||||
|
channel = SimpleNamespace(
|
||||||
|
send_webui_protocol_error=AsyncMock(),
|
||||||
|
gateway=SimpleNamespace(session_manager=MagicMock()),
|
||||||
|
)
|
||||||
|
|
||||||
|
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||||
|
|
||||||
|
channel.send_webui_protocol_error.assert_awaited_once_with(connection, detail)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_handler_reports_unavailable_session_manager() -> None:
|
||||||
|
connection = object()
|
||||||
|
channel = SimpleNamespace(
|
||||||
|
send_webui_protocol_error=AsyncMock(),
|
||||||
|
gateway=SimpleNamespace(session_manager=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
await forking.handle_webui_fork_chat(
|
||||||
|
channel,
|
||||||
|
connection,
|
||||||
|
{"source_chat_id": "source", "before_user_index": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.send_webui_protocol_error.assert_awaited_once_with(
|
||||||
|
connection,
|
||||||
|
"session_manager_unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_handler_maps_invalid_source_and_internal_failure_to_stable_errors(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
connection = object()
|
||||||
|
channel = SimpleNamespace(
|
||||||
|
send_webui_protocol_error=AsyncMock(),
|
||||||
|
gateway=SimpleNamespace(session_manager=MagicMock()),
|
||||||
|
logger=SimpleNamespace(warning=MagicMock()),
|
||||||
|
)
|
||||||
|
envelope = {"source_chat_id": "source", "before_user_index": 0}
|
||||||
|
monkeypatch.setattr(forking, "create_webui_chat_fork", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
|
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||||
|
channel.send_webui_protocol_error.assert_awaited_once_with(
|
||||||
|
connection,
|
||||||
|
"invalid fork source or index",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.send_webui_protocol_error.reset_mock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
forking,
|
||||||
|
"create_webui_chat_fork",
|
||||||
|
MagicMock(side_effect=RuntimeError("broken transcript")),
|
||||||
|
)
|
||||||
|
await forking.handle_webui_fork_chat(channel, connection, envelope)
|
||||||
|
|
||||||
|
channel.logger.warning.assert_called_once_with("fork_chat failed: {}", ANY)
|
||||||
|
channel.send_webui_protocol_error.assert_awaited_once_with(connection, "fork_chat_failed")
|
||||||
@@ -1,22 +1,70 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
McpPresetError,
|
McpPresetError,
|
||||||
custom_mcp_action,
|
custom_mcp_action,
|
||||||
mcp_presets_action,
|
mcp_presets_action,
|
||||||
mcp_presets_payload,
|
mcp_presets_payload,
|
||||||
|
mcp_presets_settings_action,
|
||||||
mcp_presets_test_action,
|
mcp_presets_test_action,
|
||||||
normalize_mcp_preset_mentions,
|
normalize_mcp_preset_mentions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_agent_plugin(workspace: Path) -> None:
|
||||||
|
root = workspace / "plugins" / "desktop"
|
||||||
|
command = root / "bin" / "server"
|
||||||
|
command.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
command.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
assets = root / "assets"
|
||||||
|
assets.mkdir()
|
||||||
|
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||||
|
(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",
|
||||||
|
"logo": "./assets/icon.png",
|
||||||
|
"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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -55,6 +103,66 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||||
|
|
||||||
|
|
||||||
|
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["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
|
||||||
|
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(
|
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -138,3 +139,62 @@ async def test_model_preset_mutation_routes(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert json.loads(response.body)["routed"] == function_name
|
assert json.loads(response.body)["routed"] == function_name
|
||||||
assert captured["query"] == expected_query
|
assert captured["query"] == expected_query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("update_info", "expected"),
|
||||||
|
[
|
||||||
|
(None, {"updateAvailable": None}),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"latestVersion": "1.3.0",
|
||||||
|
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updateAvailable": {
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"latestVersion": "1.3.0",
|
||||||
|
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_check_route_returns_stable_payload(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
update_info: dict[str, str] | None,
|
||||||
|
expected: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_routes.check_for_update",
|
||||||
|
lambda: update_info,
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
|
||||||
|
|
||||||
|
response = await _router().dispatch(None, request, request.path)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert json.loads(response.body) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_check_route_enforces_auth_and_bounds_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
check = MagicMock(side_effect=RuntimeError("upstream secret body"))
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_routes.check_for_update", check)
|
||||||
|
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
|
||||||
|
|
||||||
|
unauthorized = await _router(authorized=False).dispatch(None, request, request.path)
|
||||||
|
assert unauthorized is not None
|
||||||
|
assert unauthorized.status_code == 401
|
||||||
|
check.assert_not_called()
|
||||||
|
|
||||||
|
failed = await _router().dispatch(None, request, request.path)
|
||||||
|
assert failed is not None
|
||||||
|
assert failed.status_code == 500
|
||||||
|
assert json.loads(failed.body) == {"error": "version check failed"}
|
||||||
|
assert "upstream secret body" not in failed.body.decode()
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import nanobot.webui.version_check as version_check
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_version_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(version_check, "_cache", (0.0, None))
|
||||||
|
monkeypatch.setattr(version_check.time, "monotonic", lambda: 1_000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _pypi_response(latest: object) -> MagicMock:
|
||||||
|
response = MagicMock()
|
||||||
|
response.json.return_value = {"info": {"version": latest}}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_check_reports_only_a_newer_release_and_caches_it(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
get = MagicMock(return_value=_pypi_response("1.3.0"))
|
||||||
|
monkeypatch.setattr(version_check, "__version__", "1.2.0")
|
||||||
|
monkeypatch.setattr(version_check.httpx, "get", get)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"currentVersion": "1.2.0",
|
||||||
|
"latestVersion": "1.3.0",
|
||||||
|
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
|
||||||
|
}
|
||||||
|
assert version_check.check_for_update() == expected
|
||||||
|
assert version_check.check_for_update() == expected
|
||||||
|
get.assert_called_once_with(
|
||||||
|
"https://pypi.org/pypi/nanobot-ai/json",
|
||||||
|
timeout=5.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("latest", ["1.2.0", "1.1.9", "not-a-version", 42, None])
|
||||||
|
def test_version_check_ignores_non_newer_or_invalid_releases(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
latest: object,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(version_check, "__version__", "1.2.0")
|
||||||
|
monkeypatch.setattr(version_check.httpx, "get", lambda *_args, **_kwargs: _pypi_response(latest))
|
||||||
|
|
||||||
|
assert version_check.check_for_update() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_check_treats_network_failure_as_best_effort(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
get = MagicMock(side_effect=TimeoutError("offline"))
|
||||||
|
monkeypatch.setattr(version_check.httpx, "get", get)
|
||||||
|
|
||||||
|
assert version_check.check_for_update() is None
|
||||||
|
|
||||||
|
# Failures are not cached, so a later explicit check can recover.
|
||||||
|
assert version_check.check_for_update() is None
|
||||||
|
assert get.call_count == 2
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -61,6 +62,8 @@
|
|||||||
|
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||||
|
|
||||||
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
||||||
|
|
||||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
@@ -103,6 +106,8 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
|
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
|
||||||
|
|
||||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
||||||
|
|
||||||
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
||||||
@@ -191,6 +196,10 @@
|
|||||||
|
|
||||||
"@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="],
|
"@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||||
|
|
||||||
|
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -209,6 +218,8 @@
|
|||||||
|
|
||||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||||
|
|
||||||
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|
||||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
||||||
@@ -467,6 +478,8 @@
|
|||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
|
"@vitest/coverage-v8": ["@vitest/coverage-v8@2.1.9", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.7", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.12", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^1.2.0" }, "peerDependencies": { "@vitest/browser": "2.1.9", "vitest": "2.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ=="],
|
||||||
|
|
||||||
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
||||||
|
|
||||||
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
|
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
|
||||||
@@ -673,6 +686,8 @@
|
|||||||
|
|
||||||
"dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="],
|
"dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="],
|
||||||
|
|
||||||
|
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
@@ -741,6 +756,8 @@
|
|||||||
|
|
||||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||||
|
|
||||||
|
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||||
|
|
||||||
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||||
|
|
||||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
@@ -755,6 +772,8 @@
|
|||||||
|
|
||||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
|
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
|
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
|
||||||
@@ -763,6 +782,8 @@
|
|||||||
|
|
||||||
"happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
|
"happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||||
|
|
||||||
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
|
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
|
||||||
@@ -799,6 +820,8 @@
|
|||||||
|
|
||||||
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||||
|
|
||||||
|
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||||
|
|
||||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||||
|
|
||||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||||
@@ -845,6 +868,16 @@
|
|||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||||
|
|
||||||
|
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||||
|
|
||||||
|
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
|
||||||
|
|
||||||
|
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||||
|
|
||||||
|
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
@@ -893,6 +926,10 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
|
||||||
|
|
||||||
|
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||||
|
|
||||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
|
|
||||||
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||||
@@ -999,6 +1036,8 @@
|
|||||||
|
|
||||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
@@ -1023,6 +1062,8 @@
|
|||||||
|
|
||||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
"package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="],
|
"package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="],
|
||||||
|
|
||||||
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
|
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
|
||||||
@@ -1037,6 +1078,8 @@
|
|||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||||
@@ -1163,6 +1206,8 @@
|
|||||||
|
|
||||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
|
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||||
@@ -1175,10 +1220,14 @@
|
|||||||
|
|
||||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||||
|
|
||||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||||
@@ -1189,6 +1238,8 @@
|
|||||||
|
|
||||||
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
|
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
|
||||||
@@ -1197,6 +1248,8 @@
|
|||||||
|
|
||||||
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||||
|
|
||||||
|
"test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="],
|
||||||
|
|
||||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||||
|
|
||||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
@@ -1295,6 +1348,8 @@
|
|||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||||
@@ -1317,6 +1372,12 @@
|
|||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
@@ -1353,6 +1414,8 @@
|
|||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||||
|
|
||||||
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||||
|
|
||||||
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||||
@@ -1365,12 +1428,16 @@
|
|||||||
|
|
||||||
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
|
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
|
||||||
|
|
||||||
|
"make-dir/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||||
|
|
||||||
"mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
"mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||||
|
|
||||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||||
|
|
||||||
|
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||||
|
|
||||||
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
|
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
|
||||||
@@ -1387,14 +1454,24 @@
|
|||||||
|
|
||||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||||
|
|
||||||
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||||
|
|
||||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||||
|
|
||||||
|
"glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||||
|
|
||||||
"hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
"hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||||
|
|
||||||
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||||
@@ -1415,6 +1492,8 @@
|
|||||||
|
|
||||||
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
|
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||||
|
|
||||||
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|||||||
Generated
+532
@@ -44,6 +44,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -75,6 +76,20 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ampproject/remapping": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@antfu/install-pkg": {
|
"node_modules/@antfu/install-pkg": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||||
@@ -348,6 +363,13 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "0.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
|
||||||
|
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@braintree/sanitize-url": {
|
"node_modules/@braintree/sanitize-url": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
||||||
@@ -990,6 +1012,119 @@
|
|||||||
"import-meta-resolve": "^4.2.0"
|
"import-meta-resolve": "^4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@isaacs/cliui": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^5.1.2",
|
||||||
|
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||||
|
"strip-ansi": "^7.0.1",
|
||||||
|
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||||
|
"wrap-ansi": "^8.1.0",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
|
||||||
|
"version": "9.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eastasianwidth": "^0.2.0",
|
||||||
|
"emoji-regex": "^9.2.2",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.1.0",
|
||||||
|
"string-width": "^5.0.1",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@istanbuljs/schema": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1071,6 +1206,17 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgjs/parseargs": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -3003,6 +3149,39 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "2.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz",
|
||||||
|
"integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ampproject/remapping": "^2.3.0",
|
||||||
|
"@bcoe/v8-coverage": "^0.2.3",
|
||||||
|
"debug": "^4.3.7",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-lib-source-maps": "^5.0.6",
|
||||||
|
"istanbul-reports": "^3.1.7",
|
||||||
|
"magic-string": "^0.30.12",
|
||||||
|
"magicast": "^0.3.5",
|
||||||
|
"std-env": "^3.8.0",
|
||||||
|
"test-exclude": "^7.0.1",
|
||||||
|
"tinyrainbow": "^1.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vitest/browser": "2.1.9",
|
||||||
|
"vitest": "2.1.9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4247,6 +4426,13 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eastasianwidth": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.340",
|
"version": "1.5.340",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4703,6 +4889,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/foreground-child": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"signal-exit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/format": {
|
"node_modules/format": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4768,6 +4971,27 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "10.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
|
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4779,6 +5003,39 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "17.6.0",
|
"version": "17.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
|
||||||
@@ -4810,6 +5067,16 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5143,6 +5410,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
@@ -5378,6 +5652,76 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-source-maps": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.23",
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"istanbul-lib-coverage": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "1.21.7",
|
"version": "1.21.7",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5587,6 +5931,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.25.4",
|
||||||
|
"@babel/types": "^7.25.4",
|
||||||
|
"source-map-js": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/markdown-table": {
|
"node_modules/markdown-table": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -6573,6 +6958,16 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -6699,6 +7094,13 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
||||||
@@ -6761,6 +7163,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
|
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7619,6 +8045,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7697,6 +8136,22 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width-cjs": {
|
||||||
|
"name": "string-width",
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stringify-entities": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -7729,6 +8184,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-ansi-cjs": {
|
||||||
|
"name": "strip-ansi",
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7789,6 +8258,19 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-preserve-symlinks-flag": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7864,6 +8346,21 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/test-exclude": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@istanbuljs/schema": "^0.1.2",
|
||||||
|
"glob": "^10.4.1",
|
||||||
|
"minimatch": "^10.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8521,6 +9018,41 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs": {
|
||||||
|
"name": "wrap-ansi",
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"build": "tsc -p tsconfig.build.json && vite build",
|
"build": "tsc -p tsconfig.build.json && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint": "cd .. && eslint --config webui/eslint.config.js webui/src \"nanobot/channels/*/webui/**/*.{ts,tsx}\" --max-warnings 0"
|
"lint": "cd .. && eslint --config webui/eslint.config.js webui/src \"nanobot/channels/*/webui/**/*.{ts,tsx}\" --max-warnings 0"
|
||||||
},
|
},
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
|||||||
@@ -7383,7 +7383,11 @@ function AppsCatalogSettings({
|
|||||||
]
|
]
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery);
|
if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery);
|
||||||
return filter === "ready" ? appsReady(item) : item.kind === filter;
|
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";
|
||||||
})
|
})
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const rank = Number(!appsReady(left)) - Number(!appsReady(right));
|
const rank = Number(!appsReady(left)) - Number(!appsReady(right));
|
||||||
@@ -7623,6 +7627,7 @@ function McpAppsCatalogRow({
|
|||||||
const testBusy = actionKey === `test:${preset.name}`;
|
const testBusy = actionKey === `test:${preset.name}`;
|
||||||
const toolsBusy = actionKey === `tools:${preset.name}`;
|
const toolsBusy = actionKey === `tools:${preset.name}`;
|
||||||
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
|
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
|
||||||
|
const agentPlugin = preset.source === "agent-plugin";
|
||||||
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
|
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
|
||||||
const hasFields = preset.required_fields.length > 0;
|
const hasFields = preset.required_fields.length > 0;
|
||||||
const needsSetupInput = missingFields.length > 0;
|
const needsSetupInput = missingFields.length > 0;
|
||||||
@@ -7634,8 +7639,13 @@ function McpAppsCatalogRow({
|
|||||||
const enabledTools = preset.enabled_tools ?? ["*"];
|
const enabledTools = preset.enabled_tools ?? ["*"];
|
||||||
const allowAllTools = enabledTools.includes("*");
|
const allowAllTools = enabledTools.includes("*");
|
||||||
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
|
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
|
||||||
const description = preset.description || preset.note || preset.requires || preset.name;
|
const description = preset.description || preset.note || preset.name;
|
||||||
const statusLabel = mcpPresetStatusLabel(preset.status, tx);
|
const detail = agentPlugin && preset.requires
|
||||||
|
? `${description} · ${preset.requires}`
|
||||||
|
: description || preset.requires;
|
||||||
|
const statusLabel = agentPlugin
|
||||||
|
? tx("settings.apps.pluginEnabled", "Plugin enabled")
|
||||||
|
: mcpPresetStatusLabel(preset.status, tx);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preset.configured || !preset.install_supported) setSetupOpen(false);
|
if (preset.configured || !preset.install_supported) setSetupOpen(false);
|
||||||
@@ -7668,9 +7678,13 @@ function McpAppsCatalogRow({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-baseline gap-2">
|
<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>
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
|
||||||
<AppsTypeBadge>{tx("settings.apps.mcpLabel", "Integration")}</AppsTypeBadge>
|
<AppsTypeBadge>
|
||||||
|
{agentPlugin
|
||||||
|
? tx("settings.apps.pluginLabel", "Plugin")
|
||||||
|
: tx("settings.apps.mcpLabel", "Integration")}
|
||||||
|
</AppsTypeBadge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
|
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{detail}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{readyInstalled ? (
|
{readyInstalled ? (
|
||||||
@@ -7687,35 +7701,41 @@ function McpAppsCatalogRow({
|
|||||||
</AppsActionButton>
|
</AppsActionButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
{!agentPlugin ? (
|
||||||
<PlayCircle aria-hidden />
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||||
{tx("settings.mcp.test", "Test")}
|
<PlayCircle aria-hidden />
|
||||||
</DropdownMenuItem>
|
{tx("settings.mcp.test", "Test")}
|
||||||
{toolNames.length ? (
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{!agentPlugin && toolNames.length ? (
|
||||||
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
||||||
<SlidersHorizontal aria-hidden />
|
<SlidersHorizontal aria-hidden />
|
||||||
{tx("settings.mcp.toolScope", "Tools")}
|
{tx("settings.mcp.toolScope", "Tools")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
tone="destructive"
|
tone={agentPlugin ? undefined : "destructive"}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onAction("remove", preset.name)}
|
onClick={() => onAction("remove", preset.name)}
|
||||||
>
|
>
|
||||||
<Trash2 aria-hidden />
|
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
||||||
{tx("settings.mcp.remove", "Remove")}
|
{agentPlugin
|
||||||
|
? tx("settings.apps.pluginDisable", "Disable")
|
||||||
|
: tx("settings.mcp.remove", "Remove")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AppsActionButton
|
{!agentPlugin ? (
|
||||||
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
<AppsActionButton
|
||||||
busy={removeBusy}
|
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
||||||
disabled={busy && !removeBusy}
|
busy={removeBusy}
|
||||||
tone="danger"
|
disabled={busy && !removeBusy}
|
||||||
onClick={() => onAction("remove", preset.name)}
|
tone="danger"
|
||||||
>
|
onClick={() => onAction("remove", preset.name)}
|
||||||
<Trash2 className="h-4 w-4" aria-hidden />
|
>
|
||||||
</AppsActionButton>
|
<Trash2 className="h-4 w-4" aria-hidden />
|
||||||
|
</AppsActionButton>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : preset.installed && !preset.configured ? (
|
) : preset.installed && !preset.configured ? (
|
||||||
<AppsActionButton
|
<AppsActionButton
|
||||||
@@ -8212,6 +8232,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
|
|||||||
const bg = preset.brand_color || "hsl(var(--muted))";
|
const bg = preset.brand_color || "hsl(var(--muted))";
|
||||||
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
||||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||||
|
const packagedLogo = preset.logo_url?.startsWith("data:image/") === true;
|
||||||
const initials = preset.display_name
|
const initials = preset.display_name
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -8219,7 +8240,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
|
|||||||
.map((part) => part[0]?.toUpperCase())
|
.map((part) => part[0]?.toUpperCase())
|
||||||
.join("") || preset.name.slice(0, 2).toUpperCase();
|
.join("") || preset.name.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
if (showBrandLogos && logoUrl) {
|
if ((showBrandLogos || packagedLogo) && logoUrl) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
||||||
|
|||||||
@@ -2383,7 +2383,7 @@ export function ThreadComposer({
|
|||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
"mx-3 mb-1 max-h-24 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
||||||
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
|
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
|
||||||
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
|
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const VOICE_MIME_CANDIDATES = [
|
|||||||
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
||||||
export type VoiceRecorderErrorKey =
|
export type VoiceRecorderErrorKey =
|
||||||
| "failed"
|
| "failed"
|
||||||
|
| "insecureContext"
|
||||||
| "noDevice"
|
| "noDevice"
|
||||||
| "notConfigured"
|
| "notConfigured"
|
||||||
| "permission"
|
| "permission"
|
||||||
@@ -163,6 +164,10 @@ export function useVoiceRecorder({
|
|||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
||||||
onClearError();
|
onClearError();
|
||||||
|
if (window.isSecureContext === false) {
|
||||||
|
onError("insecureContext");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mediaDevices = navigator.mediaDevices;
|
const mediaDevices = navigator.mediaDevices;
|
||||||
const MediaRecorderCtor = mediaRecorderConstructor();
|
const MediaRecorderCtor = mediaRecorderConstructor();
|
||||||
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
||||||
|
|||||||
@@ -584,6 +584,9 @@
|
|||||||
"description": "Add tools to nanobot, then @ them in chat.",
|
"description": "Add tools to nanobot, then @ them in chat.",
|
||||||
"cliLabel": "App",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "Integration",
|
"mcpLabel": "Integration",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin enabled",
|
||||||
|
"pluginDisable": "Disable",
|
||||||
"channelLabel": "Channel",
|
"channelLabel": "Channel",
|
||||||
"featureLabel": "Feature",
|
"featureLabel": "Feature",
|
||||||
"filterAll": "Ready",
|
"filterAll": "Ready",
|
||||||
@@ -1147,6 +1150,7 @@
|
|||||||
"recordingStatus": "Recording {{time}}"
|
"recordingStatus": "Recording {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "Chrome and other browsers block microphone access on remote HTTP pages for security. Open this WebUI over HTTPS to use voice input.",
|
||||||
"unsupported": "Voice input is not supported in this browser.",
|
"unsupported": "Voice input is not supported in this browser.",
|
||||||
"permission": "Allow microphone access in the address bar, then retry.",
|
"permission": "Allow microphone access in the address bar, then retry.",
|
||||||
"notConfigured": "Configure a transcription provider first.",
|
"notConfigured": "Configure a transcription provider first.",
|
||||||
|
|||||||
@@ -571,6 +571,9 @@
|
|||||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||||
"cliLabel": "Aplicación",
|
"cliLabel": "Aplicación",
|
||||||
"mcpLabel": "Integración",
|
"mcpLabel": "Integración",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin activado",
|
||||||
|
"pluginDisable": "Desactivar",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Función",
|
"featureLabel": "Función",
|
||||||
"filterAll": "Listo",
|
"filterAll": "Listo",
|
||||||
@@ -1134,6 +1137,7 @@
|
|||||||
"recordingStatus": "Grabando {{time}}"
|
"recordingStatus": "Grabando {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"unsupported": "Este navegador no admite entrada de voz.",
|
||||||
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
|
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
|
||||||
"notConfigured": "Configura primero un proveedor de transcripción.",
|
"notConfigured": "Configura primero un proveedor de transcripción.",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||||
"cliLabel": "Application",
|
"cliLabel": "Application",
|
||||||
"mcpLabel": "Intégration",
|
"mcpLabel": "Intégration",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin activé",
|
||||||
|
"pluginDisable": "Désactiver",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Fonction",
|
"featureLabel": "Fonction",
|
||||||
"filterAll": "Prêts",
|
"filterAll": "Prêts",
|
||||||
@@ -1133,6 +1136,7 @@
|
|||||||
"recordingStatus": "Enregistrement {{time}}"
|
"recordingStatus": "Enregistrement {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"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.",
|
"permission": "Autorisez le microphone dans la barre d'adresse, puis réessayez.",
|
||||||
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
|
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||||
"cliLabel": "Aplikasi",
|
"cliLabel": "Aplikasi",
|
||||||
"mcpLabel": "Integrasi",
|
"mcpLabel": "Integrasi",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin aktif",
|
||||||
|
"pluginDisable": "Nonaktifkan",
|
||||||
"channelLabel": "Kanal",
|
"channelLabel": "Kanal",
|
||||||
"featureLabel": "Fitur",
|
"featureLabel": "Fitur",
|
||||||
"filterAll": "Siap",
|
"filterAll": "Siap",
|
||||||
@@ -1133,6 +1136,7 @@
|
|||||||
"recordingStatus": "Merekam {{time}}"
|
"recordingStatus": "Merekam {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"unsupported": "Input suara tidak didukung di browser ini.",
|
||||||
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
|
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
|
||||||
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
|
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||||
"cliLabel": "アプリ",
|
"cliLabel": "アプリ",
|
||||||
"mcpLabel": "連携",
|
"mcpLabel": "連携",
|
||||||
|
"pluginLabel": "プラグイン",
|
||||||
|
"pluginEnabled": "プラグインは有効です",
|
||||||
|
"pluginDisable": "無効にする",
|
||||||
"channelLabel": "チャンネル",
|
"channelLabel": "チャンネル",
|
||||||
"featureLabel": "機能",
|
"featureLabel": "機能",
|
||||||
"filterAll": "使用可能",
|
"filterAll": "使用可能",
|
||||||
@@ -1133,6 +1136,7 @@
|
|||||||
"recordingStatus": "録音中 {{time}}"
|
"recordingStatus": "録音中 {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "セキュリティ上、Chrome などのブラウザーはリモートの HTTP ページからのマイクアクセスをブロックします。音声入力を使用するには、この WebUI を HTTPS で開いてください。",
|
||||||
"unsupported": "このブラウザーは音声入力に対応していません。",
|
"unsupported": "このブラウザーは音声入力に対応していません。",
|
||||||
"permission": "アドレスバーでマイクを許可してから再試行してください。",
|
"permission": "アドレスバーでマイクを許可してから再試行してください。",
|
||||||
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
|
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||||
"cliLabel": "앱",
|
"cliLabel": "앱",
|
||||||
"mcpLabel": "연동",
|
"mcpLabel": "연동",
|
||||||
|
"pluginLabel": "플러그인",
|
||||||
|
"pluginEnabled": "플러그인 활성화됨",
|
||||||
|
"pluginDisable": "비활성화",
|
||||||
"channelLabel": "채널",
|
"channelLabel": "채널",
|
||||||
"featureLabel": "기능",
|
"featureLabel": "기능",
|
||||||
"filterAll": "사용 가능",
|
"filterAll": "사용 가능",
|
||||||
@@ -1133,6 +1136,7 @@
|
|||||||
"recordingStatus": "녹음 중 {{time}}"
|
"recordingStatus": "녹음 중 {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "보안을 위해 Chrome 및 기타 브라우저는 원격 HTTP 페이지의 마이크 접근을 차단합니다. 음성 입력을 사용하려면 HTTPS로 이 WebUI를 여세요.",
|
||||||
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
|
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
|
||||||
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
|
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
|
||||||
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
|
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
|
||||||
|
|||||||
@@ -584,6 +584,9 @@
|
|||||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||||
"cliLabel": "Aplicativo",
|
"cliLabel": "Aplicativo",
|
||||||
"mcpLabel": "Integração",
|
"mcpLabel": "Integração",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin ativado",
|
||||||
|
"pluginDisable": "Desativar",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Recurso",
|
"featureLabel": "Recurso",
|
||||||
"filterAll": "Prontos",
|
"filterAll": "Prontos",
|
||||||
@@ -1147,6 +1150,7 @@
|
|||||||
"recordingStatus": "Gravando {{time}}"
|
"recordingStatus": "Gravando {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"unsupported": "A entrada de voz não é compatível com este navegador.",
|
||||||
"permission": "Permita o microfone na barra de endereço e tente novamente.",
|
"permission": "Permita o microfone na barra de endereço e tente novamente.",
|
||||||
"notConfigured": "Configure primeiro um provedor de transcrição.",
|
"notConfigured": "Configure primeiro um provedor de transcrição.",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||||
"cliLabel": "Ứng dụng",
|
"cliLabel": "Ứng dụng",
|
||||||
"mcpLabel": "Tích hợp",
|
"mcpLabel": "Tích hợp",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin đã bật",
|
||||||
|
"pluginDisable": "Tắt",
|
||||||
"channelLabel": "Kênh",
|
"channelLabel": "Kênh",
|
||||||
"featureLabel": "Tính năng",
|
"featureLabel": "Tính năng",
|
||||||
"filterAll": "Sẵn sàng",
|
"filterAll": "Sẵn sàng",
|
||||||
@@ -1133,6 +1136,7 @@
|
|||||||
"recordingStatus": "Đang ghi {{time}}"
|
"recordingStatus": "Đang ghi {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"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.",
|
"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.",
|
"notConfigured": "Hãy cấu hình nhà cung cấp chép lời trước.",
|
||||||
|
|||||||
@@ -584,6 +584,9 @@
|
|||||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||||
"cliLabel": "应用",
|
"cliLabel": "应用",
|
||||||
"mcpLabel": "集成",
|
"mcpLabel": "集成",
|
||||||
|
"pluginLabel": "插件",
|
||||||
|
"pluginEnabled": "插件已启用",
|
||||||
|
"pluginDisable": "停用",
|
||||||
"channelLabel": "渠道",
|
"channelLabel": "渠道",
|
||||||
"featureLabel": "能力",
|
"featureLabel": "能力",
|
||||||
"filterAll": "可用",
|
"filterAll": "可用",
|
||||||
@@ -1146,6 +1149,7 @@
|
|||||||
"recordingStatus": "正在录音 {{time}}"
|
"recordingStatus": "正在录音 {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "出于安全考虑,Chrome 等浏览器会阻止远程 HTTP 页面访问麦克风。请通过 HTTPS 打开此 WebUI 以使用语音输入。",
|
||||||
"unsupported": "当前浏览器不支持语音输入。",
|
"unsupported": "当前浏览器不支持语音输入。",
|
||||||
"permission": "请在地址栏允许麦克风后重试。",
|
"permission": "请在地址栏允许麦克风后重试。",
|
||||||
"notConfigured": "请先配置转写提供商。",
|
"notConfigured": "请先配置转写提供商。",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||||
"cliLabel": "應用程式",
|
"cliLabel": "應用程式",
|
||||||
"mcpLabel": "整合",
|
"mcpLabel": "整合",
|
||||||
|
"pluginLabel": "外掛",
|
||||||
|
"pluginEnabled": "外掛已啟用",
|
||||||
|
"pluginDisable": "停用",
|
||||||
"channelLabel": "通訊管道",
|
"channelLabel": "通訊管道",
|
||||||
"featureLabel": "功能",
|
"featureLabel": "功能",
|
||||||
"filterAll": "就緒",
|
"filterAll": "就緒",
|
||||||
@@ -1133,6 +1136,7 @@
|
|||||||
"recordingStatus": "正在錄音 {{time}}"
|
"recordingStatus": "正在錄音 {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "基於安全考量,Chrome 等瀏覽器會阻止遠端 HTTP 頁面存取麥克風。請透過 HTTPS 開啟此 WebUI 以使用語音輸入。",
|
||||||
"unsupported": "目前瀏覽器不支援語音輸入。",
|
"unsupported": "目前瀏覽器不支援語音輸入。",
|
||||||
"permission": "請在網址列允許麥克風後重試。",
|
"permission": "請在網址列允許麥克風後重試。",
|
||||||
"notConfigured": "請先設定轉寫供應商。",
|
"notConfigured": "請先設定轉寫供應商。",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -304,6 +304,7 @@ describe("App layout", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2252,7 +2253,7 @@ describe("App layout", () => {
|
|||||||
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
|
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
|
||||||
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
||||||
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
await user.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -399,9 +399,14 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() => new Promise<Response>(() => {})),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
localStorage.removeItem("nanobot-webui.settings-preferences");
|
localStorage.removeItem("nanobot-webui.settings-preferences");
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -574,6 +579,66 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
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 () => {
|
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
|||||||
@@ -651,6 +651,49 @@ describe("ThreadComposer", () => {
|
|||||||
expect(onSend).not.toHaveBeenCalled();
|
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 () => {
|
it("converts voice recordings to wav for Xiaomi MiMo transcription", async () => {
|
||||||
mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" }));
|
mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" }));
|
||||||
const { decodeAudioData } = mockVoiceAudioInput(
|
const { decodeAudioData } = mockVoiceAudioInput(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +56,9 @@ describe("ThreadMessages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("preserves an answer's markdown tree across completion and the next prompt", async () => {
|
it("preserves an answer's markdown tree across completion and the next prompt", async () => {
|
||||||
await preloadMarkdownText();
|
await act(async () => {
|
||||||
|
await preloadMarkdownText();
|
||||||
|
});
|
||||||
const turnId = "turn-1";
|
const turnId = "turn-1";
|
||||||
const streaming: UIMessage[] = [
|
const streaming: UIMessage[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -161,6 +161,18 @@ export default defineConfig(({ mode }) => {
|
|||||||
environment: "happy-dom",
|
environment: "happy-dom",
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ["./src/tests/setup.ts"],
|
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