mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
refactor(plugins): minimize integration surface
This commit is contained in:
+2
-12
@@ -2309,18 +2309,8 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|
||||
### Agent Plugins v1
|
||||
|
||||
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
|
||||
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may provide skills, MCP
|
||||
servers, or both:
|
||||
|
||||
```text
|
||||
plugins/
|
||||
└── release-tools/
|
||||
├── plugin.json
|
||||
├── mcp.json
|
||||
└── skills/
|
||||
└── release-notes/
|
||||
└── SKILL.md
|
||||
```
|
||||
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may add `mcp.json`,
|
||||
`skills/<name>/SKILL.md`, or both.
|
||||
|
||||
Directory presence means installed; activation is an explicit trust decision in **Apps**.
|
||||
Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills
|
||||
|
||||
+31
-55
@@ -29,15 +29,6 @@ _SETUP_TIMEOUT_SECONDS = 600
|
||||
_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, locally installed Agent Plugins v1 package."""
|
||||
@@ -71,14 +62,8 @@ def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
root = _contained_directory(workspace / "plugins", workspace)
|
||||
if root is None:
|
||||
return []
|
||||
try:
|
||||
candidates = sorted(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:
|
||||
for candidate in _children(root, "Agent Plugins directory"):
|
||||
plugin_root = _contained_directory(candidate, root)
|
||||
if plugin_root is None:
|
||||
continue
|
||||
@@ -88,13 +73,14 @@ def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
return plugins
|
||||
|
||||
|
||||
def enabled_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
|
||||
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
|
||||
"""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
|
||||
return [
|
||||
skill
|
||||
for plugin in _discover_agent_plugins(workspace)
|
||||
if _enabled(workspace, plugin.name)
|
||||
for skill in _discover_plugin_skills(plugin.name, plugin.root)
|
||||
]
|
||||
|
||||
|
||||
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||
@@ -172,13 +158,12 @@ def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> Agent
|
||||
if plugin is None:
|
||||
raise ValueError(f"unknown Agent Plugin '{name}'")
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
version = plugin.version or "unknown"
|
||||
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"
|
||||
):
|
||||
if plugin.install_command and _setup_version(workspace, plugin.name) != version:
|
||||
_run_install(plugin, data)
|
||||
_write_state(data / "setup-version", plugin.version or "unknown")
|
||||
_write_state(data / "setup-version", version)
|
||||
_write_state(data / "enabled", "1")
|
||||
else:
|
||||
(data / "enabled").unlink(missing_ok=True)
|
||||
@@ -209,12 +194,11 @@ def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
|
||||
try:
|
||||
data = logo.read_bytes() if logo is not None else b""
|
||||
suffix = logo.suffix.lower() if logo is not None else ""
|
||||
valid = (
|
||||
if len(data) <= _MAX_LOGO_BYTES and (
|
||||
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
||||
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
||||
)
|
||||
if valid and len(data) <= _MAX_LOGO_BYTES:
|
||||
):
|
||||
return logo
|
||||
except OSError:
|
||||
pass
|
||||
@@ -334,7 +318,7 @@ 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_root = _private_directory(config_root / "plugin-data", config_root, create=create)
|
||||
state_root = _private_directory(plugin_root / workspace_id, config_root, create=create)
|
||||
state_root = _private_directory(plugin_root / workspace_id, plugin_root, create=create)
|
||||
data = state_root / name
|
||||
return _private_directory(data, state_root, create=True) if create else data
|
||||
|
||||
@@ -394,44 +378,36 @@ def _run_install(plugin: AgentPlugin, data: Path) -> None:
|
||||
raise RuntimeError(output or f"{plugin.display_name} setup failed")
|
||||
|
||||
|
||||
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]:
|
||||
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
|
||||
skills_root = _contained_directory(plugin_root / "skills", plugin_root)
|
||||
if skills_root is None:
|
||||
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:
|
||||
skills: list[tuple[str, Path]] = []
|
||||
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
|
||||
skill_root = _contained_directory(candidate, 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):
|
||||
if skill_file is None:
|
||||
continue
|
||||
skills.append(
|
||||
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name)
|
||||
)
|
||||
try:
|
||||
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError):
|
||||
metadata = None
|
||||
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
|
||||
continue
|
||||
skills.append((candidate.name, skill_file))
|
||||
return skills
|
||||
|
||||
|
||||
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool:
|
||||
def _children(root: Path, label: str) -> list[Path]:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError):
|
||||
return False
|
||||
metadata = parse_skill_metadata(content)
|
||||
if metadata is None:
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
|
||||
return False
|
||||
if not valid_skill_metadata(metadata, directory_name):
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, directory_name)
|
||||
return False
|
||||
return True
|
||||
return sorted(root.iterdir(), key=lambda path: path.name)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not inspect {}: {}", label, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _contained_directory(path: Path, root: Path) -> Path | None:
|
||||
|
||||
@@ -104,18 +104,17 @@ class SkillsLoader:
|
||||
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||
seen_names = {entry["name"] for entry in skills}
|
||||
for plugin_skill in plugin_skills:
|
||||
if plugin_skill.name in seen_names:
|
||||
for name, path in plugin_skills:
|
||||
if name in seen_names:
|
||||
continue
|
||||
skills.append(
|
||||
{
|
||||
"name": plugin_skill.name,
|
||||
"path": str(plugin_skill.path),
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"source": "plugin",
|
||||
"plugin": plugin_skill.plugin,
|
||||
}
|
||||
)
|
||||
seen_names.add(plugin_skill.name)
|
||||
seen_names.add(name)
|
||||
if self.builtin_skills and self.builtin_skills.exists():
|
||||
skills.extend(
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||
|
||||
+12
-16
@@ -212,26 +212,22 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _safe_skill_name(name: str) -> str:
|
||||
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."""
|
||||
def _skill_name(name: str, *, legacy: bool = False) -> str:
|
||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||
if not legacy:
|
||||
clean = clean.replace("_", "-")
|
||||
return f"cli-app-{clean or 'app'}"
|
||||
|
||||
|
||||
def _plugin_skill_relative_path(name: str) -> str:
|
||||
skill_name = _safe_skill_name(name)
|
||||
skill_name = _skill_name(name)
|
||||
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||
|
||||
|
||||
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
||||
"""Return a CLI App's skill path, including the legacy location."""
|
||||
canonical = _plugin_skill_relative_path(name)
|
||||
legacy = f"skills/{_legacy_skill_name(name)}/SKILL.md"
|
||||
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
|
||||
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
||||
return legacy
|
||||
return canonical
|
||||
@@ -733,7 +729,7 @@ class CliAppManager:
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
strategy = self._strategy(app)
|
||||
skill_path = _plugin_skill_relative_path(name)
|
||||
plugin_path = f"plugins/{_safe_skill_name(name)}"
|
||||
plugin_path = f"plugins/{_skill_name(name)}"
|
||||
capabilities = [
|
||||
compact_dict({
|
||||
"type": "cli",
|
||||
@@ -1054,7 +1050,7 @@ class CliAppManager:
|
||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||
return f"""---
|
||||
name: {_safe_skill_name(name)}
|
||||
name: {_skill_name(name)}
|
||||
description: {json.dumps(description, ensure_ascii=False)}
|
||||
---
|
||||
|
||||
@@ -1096,18 +1092,18 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
path = self.workspace / _plugin_skill_relative_path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||
content = normalize_skill_document(content, _safe_skill_name(name)) or self._fallback_skill(app)
|
||||
content = normalize_skill_document(content, _skill_name(name)) or self._fallback_skill(app)
|
||||
content = self._with_nanobot_skill_note(content, app)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
plugin_root = path.parents[2]
|
||||
manifest = compact_dict({
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": _safe_skill_name(str(app["name"])),
|
||||
"name": _skill_name(str(app["name"])),
|
||||
"version": str(app.get("version") or ""),
|
||||
"description": _catalog_description(app),
|
||||
})
|
||||
_write_json(plugin_root / "plugin.json", manifest)
|
||||
legacy_dir = self.workspace / "skills" / _legacy_skill_name(str(app["name"]))
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
return path
|
||||
@@ -1116,7 +1112,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||
if plugin_root.is_dir():
|
||||
shutil.rmtree(plugin_root)
|
||||
legacy_dir = self.workspace / "skills" / _legacy_skill_name(name)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
|
||||
@@ -1128,7 +1124,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
installed[str(app["name"])] = entry
|
||||
self._save_installed(installed)
|
||||
self.install_skill(app)
|
||||
set_agent_plugin_enabled(self.workspace, _safe_skill_name(str(app["name"])), True)
|
||||
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||
return entry
|
||||
|
||||
def install(self, name: str) -> dict[str, Any]:
|
||||
|
||||
@@ -878,8 +878,6 @@ def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
|
||||
"brand_color": plugin.accent_color,
|
||||
"required_fields": [],
|
||||
"connection_summary": ", ".join(state.mcp_servers),
|
||||
"enabled_tools": ["*"],
|
||||
"tool_names": [],
|
||||
"source": "agent-plugin",
|
||||
}
|
||||
|
||||
@@ -902,16 +900,12 @@ def mcp_presets_payload(
|
||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||
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
|
||||
_agent_plugin_payload(state)
|
||||
for state in discover_agent_plugin_states(config.workspace_path)
|
||||
if (state.mcp_servers or state.plugin.install_command)
|
||||
and f"plugin-{state.plugin.name}" not in existing_names
|
||||
]
|
||||
payload: dict[str, Any] = {
|
||||
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||
@@ -1449,16 +1443,13 @@ async def mcp_presets_settings_action(
|
||||
if name.startswith("plugin-"):
|
||||
plugin_config = load_config(config_path) if config_path is not None else load_config()
|
||||
plugin_name = name.removeprefix("plugin-")
|
||||
plugin_state = next(
|
||||
(
|
||||
state
|
||||
for state in discover_agent_plugin_states(plugin_config.workspace_path)
|
||||
if state.plugin.name == plugin_name
|
||||
and (state.mcp_servers or state.plugin.install_command)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if name not in plugin_config.tools.mcp_servers and plugin_state is not None:
|
||||
plugin_states = discover_agent_plugin_states(plugin_config.workspace_path)
|
||||
plugin_state = next((state for state in plugin_states if state.plugin.name == plugin_name), None)
|
||||
if (
|
||||
name not in plugin_config.tools.mcp_servers
|
||||
and plugin_state is not None
|
||||
and (plugin_state.mcp_servers or plugin_state.plugin.install_command)
|
||||
):
|
||||
if action not in {"enable", "disable"}:
|
||||
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||
if (
|
||||
|
||||
@@ -80,7 +80,7 @@ def _write_setup_plugin(workspace: Path) -> tuple[Path, Path]:
|
||||
|
||||
|
||||
def _loaded_plugin_skills(workspace: Path) -> list[str]:
|
||||
return [skill.name for skill in enabled_agent_plugin_skills(workspace)]
|
||||
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
|
||||
|
||||
|
||||
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
||||
@@ -95,7 +95,6 @@ def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
||||
"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"]
|
||||
|
||||
@@ -158,24 +158,15 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
||||
with pytest.raises(McpPresetError, match="enable and disable"):
|
||||
asyncio.run(plugin_action("remove"))
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -7701,11 +7701,11 @@ function McpAppsCatalogRow({
|
||||
const toolsBusy = actionKey === `tools:${preset.name}`;
|
||||
const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy;
|
||||
const agentPlugin = preset.source === "agent-plugin";
|
||||
const toggleable = preset.enabled !== undefined;
|
||||
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
|
||||
const hasFields = preset.required_fields.length > 0;
|
||||
const needsSetupInput = missingFields.length > 0;
|
||||
const pluginEnabled = agentPlugin && preset.enabled === true;
|
||||
const readyInstalled = agentPlugin ? pluginEnabled : preset.installed && preset.configured;
|
||||
const readyInstalled = preset.enabled ?? (preset.installed && preset.configured);
|
||||
const canEnable =
|
||||
preset.install_supported &&
|
||||
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
|
||||
@@ -7717,8 +7717,8 @@ function McpAppsCatalogRow({
|
||||
const detail = agentPlugin && preset.requires
|
||||
? `${description} · ${preset.requires}`
|
||||
: description || preset.requires;
|
||||
const statusLabel = agentPlugin
|
||||
? tx("settings.apps.pluginEnabled", "Plugin enabled")
|
||||
const statusLabel = toggleable
|
||||
? tx("settings.nanobotFeatures.enabled", "Enabled")
|
||||
: mcpPresetStatusLabel(preset.status, tx);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -7754,7 +7754,7 @@ function McpAppsCatalogRow({
|
||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
|
||||
<AppsTypeBadge>
|
||||
{agentPlugin
|
||||
? tx("settings.apps.pluginLabel", "Plugin")
|
||||
? tx("settings.apps.filterPlugins", "Plugins")
|
||||
: tx("settings.apps.mcpLabel", "Integration")}
|
||||
</AppsTypeBadge>
|
||||
</div>
|
||||
@@ -7775,31 +7775,31 @@ function McpAppsCatalogRow({
|
||||
</AppsActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!agentPlugin ? (
|
||||
{!toggleable ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||
<PlayCircle aria-hidden />
|
||||
{tx("settings.mcp.test", "Test")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{!agentPlugin && toolNames.length ? (
|
||||
{!toggleable && toolNames.length ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
||||
<SlidersHorizontal aria-hidden />
|
||||
{tx("settings.mcp.toolScope", "Tools")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
tone={agentPlugin ? undefined : "destructive"}
|
||||
tone={toggleable ? undefined : "destructive"}
|
||||
disabled={busy}
|
||||
onClick={() => onAction(agentPlugin ? "disable" : "remove", preset.name)}
|
||||
onClick={() => onAction(toggleable ? "disable" : "remove", preset.name)}
|
||||
>
|
||||
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
||||
{agentPlugin
|
||||
? tx("settings.apps.pluginDisable", "Disable")
|
||||
{toggleable ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
||||
{toggleable
|
||||
? tx("settings.nanobotFeatures.disable", "Disable")
|
||||
: tx("settings.mcp.remove", "Remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!agentPlugin ? (
|
||||
{!toggleable ? (
|
||||
<AppsActionButton
|
||||
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
||||
busy={removeBusy}
|
||||
@@ -7811,9 +7811,9 @@ function McpAppsCatalogRow({
|
||||
</AppsActionButton>
|
||||
) : null}
|
||||
</>
|
||||
) : agentPlugin && preset.installed ? (
|
||||
) : preset.enabled === false ? (
|
||||
<AppsActionButton
|
||||
ariaLabel={tx("settings.apps.pluginEnable", "Enable plugin")}
|
||||
ariaLabel={tx("settings.nanobotFeatures.enable", "Enable")}
|
||||
busy={enableBusy}
|
||||
onClick={() => onAction("enable", preset.name, values)}
|
||||
>
|
||||
@@ -8023,8 +8023,7 @@ function appsTitle(item: AppsCatalogItem): string {
|
||||
|
||||
function appsReady(item: AppsCatalogItem): boolean {
|
||||
if (item.kind === "cli") return item.app.installed;
|
||||
if (item.preset.source === "agent-plugin") return item.preset.enabled === true;
|
||||
return item.preset.installed && item.preset.configured;
|
||||
return item.preset.enabled ?? (item.preset.installed && item.preset.configured);
|
||||
}
|
||||
|
||||
function appsSearchText(item: AppsCatalogItem): string {
|
||||
|
||||
@@ -585,10 +585,6 @@
|
||||
"description": "Add tools to nanobot, then @ them in chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "Integration",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin enabled",
|
||||
"pluginEnable": "Enable plugin",
|
||||
"pluginDisable": "Disable",
|
||||
"channelLabel": "Channel",
|
||||
"featureLabel": "Feature",
|
||||
"filterAll": "Ready",
|
||||
|
||||
@@ -572,10 +572,6 @@
|
||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||
"cliLabel": "Aplicación",
|
||||
"mcpLabel": "Integración",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin activado",
|
||||
"pluginEnable": "Activar plugin",
|
||||
"pluginDisable": "Desactivar",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Función",
|
||||
"filterAll": "Listo",
|
||||
|
||||
@@ -571,10 +571,6 @@
|
||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||
"cliLabel": "Application",
|
||||
"mcpLabel": "Intégration",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin activé",
|
||||
"pluginEnable": "Activer le plugin",
|
||||
"pluginDisable": "Désactiver",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Fonction",
|
||||
"filterAll": "Prêts",
|
||||
|
||||
@@ -571,10 +571,6 @@
|
||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||
"cliLabel": "Aplikasi",
|
||||
"mcpLabel": "Integrasi",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin aktif",
|
||||
"pluginEnable": "Aktifkan plugin",
|
||||
"pluginDisable": "Nonaktifkan",
|
||||
"channelLabel": "Kanal",
|
||||
"featureLabel": "Fitur",
|
||||
"filterAll": "Siap",
|
||||
|
||||
@@ -571,10 +571,6 @@
|
||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||
"cliLabel": "アプリ",
|
||||
"mcpLabel": "連携",
|
||||
"pluginLabel": "プラグイン",
|
||||
"pluginEnabled": "プラグインは有効です",
|
||||
"pluginEnable": "プラグインを有効にする",
|
||||
"pluginDisable": "無効にする",
|
||||
"channelLabel": "チャンネル",
|
||||
"featureLabel": "機能",
|
||||
"filterAll": "使用可能",
|
||||
|
||||
@@ -571,10 +571,6 @@
|
||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||
"cliLabel": "앱",
|
||||
"mcpLabel": "연동",
|
||||
"pluginLabel": "플러그인",
|
||||
"pluginEnabled": "플러그인 활성화됨",
|
||||
"pluginEnable": "플러그인 활성화",
|
||||
"pluginDisable": "비활성화",
|
||||
"channelLabel": "채널",
|
||||
"featureLabel": "기능",
|
||||
"filterAll": "사용 가능",
|
||||
|
||||
@@ -585,10 +585,6 @@
|
||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||
"cliLabel": "Aplicativo",
|
||||
"mcpLabel": "Integração",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin ativado",
|
||||
"pluginEnable": "Ativar plugin",
|
||||
"pluginDisable": "Desativar",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Recurso",
|
||||
"filterAll": "Prontos",
|
||||
|
||||
@@ -571,10 +571,6 @@
|
||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||
"cliLabel": "Ứng dụng",
|
||||
"mcpLabel": "Tích hợp",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin đã bật",
|
||||
"pluginEnable": "Bật plugin",
|
||||
"pluginDisable": "Tắt",
|
||||
"channelLabel": "Kênh",
|
||||
"featureLabel": "Tính năng",
|
||||
"filterAll": "Sẵn sàng",
|
||||
|
||||
@@ -585,10 +585,6 @@
|
||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||
"cliLabel": "应用",
|
||||
"mcpLabel": "集成",
|
||||
"pluginLabel": "插件",
|
||||
"pluginEnabled": "插件已启用",
|
||||
"pluginEnable": "启用插件",
|
||||
"pluginDisable": "停用",
|
||||
"channelLabel": "渠道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "可用",
|
||||
|
||||
@@ -571,10 +571,6 @@
|
||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||
"cliLabel": "應用程式",
|
||||
"mcpLabel": "整合",
|
||||
"pluginLabel": "外掛",
|
||||
"pluginEnabled": "外掛已啟用",
|
||||
"pluginEnable": "啟用外掛",
|
||||
"pluginDisable": "停用",
|
||||
"channelLabel": "通訊管道",
|
||||
"featureLabel": "功能",
|
||||
"filterAll": "就緒",
|
||||
|
||||
@@ -9,7 +9,9 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
|
||||
}
|
||||
|
||||
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
|
||||
return payload.presets.filter((preset) => preset.installed && preset.configured);
|
||||
return payload.presets.filter(
|
||||
(preset) => preset.enabled ?? (preset.installed && preset.configured),
|
||||
);
|
||||
}
|
||||
|
||||
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { installedMcpPresetsFromPayload } from "@/lib/mcp-preset-events";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
ChannelSetupContract,
|
||||
@@ -674,6 +675,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
connection_summary: "computer-use",
|
||||
source: "agent-plugin",
|
||||
};
|
||||
expect(installedMcpPresetsFromPayload({ presets: [plugin], installed_count: 0 })).toEqual([]);
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
@@ -701,11 +703,11 @@ describe("SettingsView Apps catalog", () => {
|
||||
renderSettingsView();
|
||||
|
||||
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plugin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plugins")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enable plugin" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
@@ -715,7 +717,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
|
||||
const enabledButton = screen.getByRole("button", { name: "Plugin enabled" });
|
||||
const enabledButton = screen.getByRole("button", { name: "Enabled" });
|
||||
await waitFor(() => expect(enabledButton).toBeEnabled());
|
||||
fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
|
||||
@@ -728,7 +730,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Computer Use disabled.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Enable plugin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
||||
|
||||
Reference in New Issue
Block a user