From 418cb23da219a976fd635c529f582793e7507849 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 25 May 2026 20:07:02 +0800 Subject: [PATCH] feat(apps): unify CLI apps and MCP (#3991) * refactor(cli): load bundled apps from catalog * feat(plugins): unify CLI and MCP settings * feat(plugins): add settings category filter * style(plugins): refine settings catalog * refactor(cli): load nanobot apps from repo catalog * feat(store): add capability store entry * feat(apps): rename capability store * fix(apps): verify clean app removal * fix(apps): keep main sidebar on apps view * feat(apps): add shared app manifest protocol * fix(apps): dismiss app status message * refactor(apps): move CLI adapter under apps * refactor(apps): drop legacy cli apps package --- nanobot/agent/context.py | 2 +- nanobot/agent/tools/cli_apps.py | 2 +- nanobot/apps/__init__.py | 5 + nanobot/{cli_apps => apps/cli}/__init__.py | 4 +- nanobot/{cli_apps => apps/cli}/service.py | 237 ++- nanobot/{cli_apps => apps/cli}/utils.py | 2 +- nanobot/apps/protocol.py | 56 + nanobot/config/loader.py | 8 +- nanobot/webui/cli_apps_api.py | 2 +- nanobot/webui/mcp_presets_api.py | 152 +- tests/cli_apps/test_service.py | 181 +- tests/cli_apps/test_tool.py | 12 +- tests/cli_apps/test_utils.py | 6 +- tests/webui/test_mcp_presets_api.py | 44 + webui/src/App.tsx | 29 +- webui/src/components/Sidebar.tsx | 16 +- .../src/components/settings/SettingsView.tsx | 1503 ++++++++--------- webui/src/i18n/locales/en/common.json | 35 +- webui/src/i18n/locales/es/common.json | 29 +- webui/src/i18n/locales/fr/common.json | 29 +- webui/src/i18n/locales/id/common.json | 33 +- webui/src/i18n/locales/ja/common.json | 29 +- webui/src/i18n/locales/ko/common.json | 29 +- webui/src/i18n/locales/vi/common.json | 29 +- webui/src/i18n/locales/zh-CN/common.json | 29 +- webui/src/i18n/locales/zh-TW/common.json | 27 +- webui/src/lib/types.ts | 65 + webui/src/tests/app-layout.test.tsx | 134 ++ webui/src/tests/i18n.test.tsx | 1 + webui/src/tests/settings-view.test.tsx | 191 +++ webui/src/tests/thread-composer.test.tsx | 4 +- webui/src/tests/thread-shell.test.tsx | 4 +- 32 files changed, 2026 insertions(+), 903 deletions(-) create mode 100644 nanobot/apps/__init__.py rename nanobot/{cli_apps => apps/cli}/__init__.py (62%) rename nanobot/{cli_apps => apps/cli}/service.py (79%) rename nanobot/{cli_apps => apps/cli}/utils.py (97%) create mode 100644 nanobot/apps/protocol.py create mode 100644 webui/src/tests/settings-view.test.tsx diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index dac5e1d64..7c1779499 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -13,7 +13,7 @@ from nanobot.agent.skills import SkillsLoader from nanobot.agent.tools import mcp as mcp_tools from nanobot.agent.tools.registry import ToolRegistry from nanobot.bus.events import InboundMessage -from nanobot.cli_apps import utils as cli_app_utils +from nanobot.apps.cli import utils as cli_app_utils from nanobot.session.goal_state import goal_state_runtime_lines from nanobot.utils.helpers import ( current_time_str, diff --git a/nanobot/agent/tools/cli_apps.py b/nanobot/agent/tools/cli_apps.py index eb410ecbf..3d0c109ab 100644 --- a/nanobot/agent/tools/cli_apps.py +++ b/nanobot/agent/tools/cli_apps.py @@ -9,7 +9,7 @@ from pydantic import Field from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema -from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig +from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig from nanobot.config.schema import Base diff --git a/nanobot/apps/__init__.py b/nanobot/apps/__init__.py new file mode 100644 index 000000000..7444058f7 --- /dev/null +++ b/nanobot/apps/__init__.py @@ -0,0 +1,5 @@ +"""Shared app protocol helpers.""" + +from nanobot.apps.protocol import APP_PROTOCOL_SCHEMA, app_manifest + +__all__ = ["APP_PROTOCOL_SCHEMA", "app_manifest"] diff --git a/nanobot/cli_apps/__init__.py b/nanobot/apps/cli/__init__.py similarity index 62% rename from nanobot/cli_apps/__init__.py rename to nanobot/apps/cli/__init__.py index 9ca70839c..b9b01b918 100644 --- a/nanobot/cli_apps/__init__.py +++ b/nanobot/apps/cli/__init__.py @@ -1,6 +1,6 @@ -"""CLI Apps integration helpers.""" +"""CLI app adapter for the unified Apps domain.""" -from nanobot.cli_apps.service import ( +from nanobot.apps.cli.service import ( CliAppError, CliAppManager, CliAppsRuntimeConfig, diff --git a/nanobot/cli_apps/service.py b/nanobot/apps/cli/service.py similarity index 79% rename from nanobot/cli_apps/service.py rename to nanobot/apps/cli/service.py index 2dc3cb0a1..dfe7277c9 100644 --- a/nanobot/cli_apps/service.py +++ b/nanobot/apps/cli/service.py @@ -11,12 +11,14 @@ import subprocess import sys import time from dataclasses import dataclass +from importlib import metadata as importlib_metadata from pathlib import Path from typing import Any from urllib.parse import urlparse import httpx +from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.config.paths import get_runtime_subdir CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json" @@ -139,7 +141,7 @@ _BRANDS: dict[str, tuple[str, str]] = { _BRAND_DOMAINS: dict[str, tuple[str, str]] = { "3mf": ("3mf.io", "#00A1DE"), - "anygen": ("anygen.com", "#111827"), + "anygen": ("anygen.io", "#111827"), "clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"), "cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"), "cloudcompare": ("cloudcompare.org", "#4D83C3"), @@ -244,6 +246,29 @@ def _pip_uninstall_args_from_command(command: str) -> list[str] | None: return packages +def _console_script_distribution(entry_point: str) -> str | None: + if not entry_point: + return None + try: + distributions = importlib_metadata.distributions() + except Exception: + return None + for distribution in distributions: + try: + entry_points = distribution.entry_points + except Exception: + continue + for item in entry_points: + if item.group != "console_scripts" or item.name != entry_point: + continue + try: + name = distribution.metadata.get("Name") + except Exception: + name = None + return str(name or getattr(distribution, "name", "") or "").strip() or None + return None + + def _brand_key(value: str) -> str: return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-") @@ -540,8 +565,86 @@ class CliAppManager: "logo_url": logo_url, "brand_color": brand_color, "skill_installed": self._skill_path(name).is_file(), + "manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color), } + def _package_ref(self, app: dict[str, Any]) -> dict[str, Any] | None: + strategy = self._strategy(app) + name = "" + if strategy == "pip": + try: + uninstall = self._pip_uninstall_argv(app) + except CliAppError: + uninstall = None + name = uninstall[-1] if uninstall else "" + elif strategy == "npm": + name = str(app.get("npm_package") or "").strip() + elif strategy in {"brew", "uv"}: + try: + uninstall = self._argv_for_action(app, "uninstall") + except CliAppError: + uninstall = None + if uninstall: + name = uninstall[-1] + if not strategy or strategy in {"unsupported", "bundled"}: + return None + return compact_dict({"manager": strategy, "name": name}) + + def _manifest_payload( + self, + app: dict[str, Any], + *, + logo_url: str | None, + brand_color: str | None, + ) -> dict[str, Any]: + name = str(app["name"]) + entry_point = str(app.get("entry_point") or "") + strategy = self._strategy(app) + skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md" + capabilities = [ + compact_dict({ + "type": "cli", + "entry_point": entry_point, + "package": self._package_ref(app), + }), + {"type": "skill", "path": skill_path}, + ] + install_supported = self._install_supported(app) + install = compact_dict({ + "supported": install_supported, + "strategy": strategy, + "managed_paths": [skill_path], + "verification": ["entry_point_available"] if entry_point else [], + }) + remove = compact_dict({ + "supported": strategy != "unsupported", + "strategy": strategy, + "managed_paths": [skill_path], + "verification": ( + ["package_manager_ok", "entry_point_absent", "managed_paths_absent"] + if strategy not in {"bundled", "unsupported"} + else ["nanobot_state_absent", "managed_paths_absent"] + ), + }) + return app_manifest( + app_id=name, + display_name=str(app.get("display_name") or name), + version=str(app.get("version") or ""), + description=str(app.get("description") or ""), + category=str(app.get("category") or "uncategorized"), + source=f"cli-anything:{app.get('_source') or 'harness'}", + logo_url=logo_url, + brand_color=brand_color, + capabilities=capabilities, + install=install, + remove=remove, + trust={ + "registry": "cli-anything", + "level": "catalog", + "review_status": "catalog_entry", + }, + ) + def payload(self, *, force_refresh: bool = False) -> dict[str, Any]: apps, updated = self.catalog(force_refresh=force_refresh) installed = self._load_installed() @@ -581,7 +684,14 @@ class CliAppManager: prefix.extend(["--upgrade", "--force-reinstall"]) return prefix + args - def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]: + def _pip_uninstall_argv( + self, + app: dict[str, Any], + installed_entry: dict[str, Any] | None = None, + ) -> list[str]: + distribution = str((installed_entry or {}).get("pip_distribution") or "").strip() + if distribution: + return [sys.executable, "-m", "pip", "uninstall", "-y", distribution] uninstall_cmd = str(app.get("uninstall_cmd") or "") packages = _pip_uninstall_args_from_command(uninstall_cmd) if packages: @@ -619,14 +729,19 @@ class CliAppManager: raise CliAppError(f"unsupported {expected} command") return argv - def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None: + def _argv_for_action( + self, + app: dict[str, Any], + action: str, + installed_entry: dict[str, Any] | None = None, + ) -> list[str] | None: strategy = self._strategy(app) if strategy == "pip": if action == "install": return self._pip_install_argv(app) if action == "update": return self._pip_install_argv(app, update=True) - return self._pip_uninstall_argv(app) + return self._pip_uninstall_argv(app, installed_entry=installed_entry) if strategy == "npm": return self._npm_argv(app, action) if strategy == "brew": @@ -648,13 +763,23 @@ class CliAppManager: ) def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]: - return { + entry_point = str(app.get("entry_point") or "") + strategy = self._strategy(app) + entry: dict[str, Any] = { "version": app.get("version") or "unknown", - "entry_point": app.get("entry_point") or "", + "entry_point": entry_point, "source": app.get("_source") or "harness", - "strategy": self._strategy(app), + "strategy": strategy, "installed_at": int(_now()), } + resolved = shutil.which(entry_point) if entry_point else None + if resolved: + entry["entry_point_path"] = resolved + if strategy == "pip": + distribution = _console_script_distribution(entry_point) + if distribution: + entry["pip_distribution"] = distribution + return entry def _fetch_skill_content(self, app: dict[str, Any]) -> str | None: skill_md = str(app.get("skill_md") or "").strip() @@ -730,11 +855,13 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in if skill_dir.is_dir(): shutil.rmtree(skill_dir) - def _record_installed(self, app: dict[str, Any]) -> None: + def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]: installed = self._load_installed() - installed[str(app["name"])] = self._installed_entry(app) + entry = self._installed_entry(app) + installed[str(app["name"])] = entry self._save_installed(installed) self.install_skill(app) + return entry def install(self, name: str) -> dict[str, Any]: app = self.get_app(name) @@ -745,7 +872,14 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "") if detect_cmd and _command_exists(detect_cmd): self._record_installed(app) - return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}} + return self.payload() | { + "last_action": { + "ok": True, + "message": f"CLI for {app['display_name']} is available.", + "installed": True, + "verification": ["entry_point_available", "state_recorded"], + } + } note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app." raise CliAppError(str(note)) argv = self._argv_for_action(app, "install") @@ -754,7 +888,14 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in if result.returncode != 0: raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500) self._record_installed(app) - return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}} + return self.payload() | { + "last_action": { + "ok": True, + "message": f"Installed CLI for {app['display_name']}.", + "installed": True, + "verification": ["package_manager_ok", "state_recorded", "managed_paths_present"], + } + } def update(self, name: str) -> dict[str, Any]: app = self.get_app(name, force_refresh=True) @@ -762,30 +903,94 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in raise CliAppError("CLI app is not installed") if self._strategy(app) == "bundled": self._record_installed(app) - return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}} + return self.payload() | { + "last_action": { + "ok": True, + "message": f"Checked {app['display_name']}.", + "installed": True, + "verification": ["state_recorded"], + } + } argv = self._argv_for_action(app, "update") assert argv is not None result = self._run_argv(argv, timeout=self.runtime.install_timeout) if result.returncode != 0: raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500) self._record_installed(app) - return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}} + return self.payload() | { + "last_action": { + "ok": True, + "message": f"Updated CLI for {app['display_name']}.", + "installed": True, + "verification": ["package_manager_ok", "state_recorded", "managed_paths_present"], + } + } def uninstall(self, name: str) -> dict[str, Any]: app = self.get_app(name) installed = self._load_installed() if str(app["name"]) not in installed: raise CliAppError("CLI app is not installed") - if self._strategy(app) != "bundled": - argv = self._argv_for_action(app, "uninstall") + raw_installed_entry = installed.get(str(app["name"])) + installed_entry = raw_installed_entry if isinstance(raw_installed_entry, dict) else {} + strategy = self._strategy(app) + entry_point = str(app.get("entry_point") or "").strip() + managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip() + if strategy != "bundled": + argv = self._argv_for_action(app, "uninstall", installed_entry=installed_entry) assert argv is not None result = self._run_argv(argv, timeout=self.runtime.install_timeout) if result.returncode != 0: raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500) + still_managed = bool(managed_entry_path and Path(managed_entry_path).exists()) + still_available = bool(entry_point and shutil.which(entry_point)) + if still_managed or (not managed_entry_path and still_available): + reason = ( + f"the recorded entry point at {managed_entry_path} still exists" + if still_managed + else f"{entry_point} is still available on PATH" + ) + message = ( + f"Uninstall for {app['display_name']} completed, but {reason}, " + "so nanobot kept it installed." + ) + return self.payload() | { + "last_action": { + "ok": False, + "message": message, + "removed": False, + "still_available": True, + "verification_failed": ["entry_point_absent"], + } + } + else: + still_available = bool(entry_point and shutil.which(entry_point)) installed.pop(str(app["name"]), None) self._save_installed(installed) self.remove_skill(str(app["name"])) - return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}} + if strategy == "bundled" and still_available: + message = ( + f"Removed {app['display_name']} from nanobot. {entry_point} " + "is still available because it is managed outside nanobot." + ) + elif still_available: + message = ( + f"Uninstalled CLI for {app['display_name']}, but another {entry_point} " + "is still available on PATH." + ) + else: + message = f"Uninstalled CLI for {app['display_name']}." + return self.payload() | { + "last_action": { + "ok": True, + "message": message, + "removed": True, + "still_available": still_available, + "verification": ["state_absent", "managed_paths_absent"] + if still_available + else ["entry_point_absent", "state_absent", "managed_paths_absent"], + } + } def test(self, name: str) -> dict[str, Any]: app = self.get_app(name) diff --git a/nanobot/cli_apps/utils.py b/nanobot/apps/cli/utils.py similarity index 97% rename from nanobot/cli_apps/utils.py rename to nanobot/apps/cli/utils.py index 264cedc25..8cfc7870c 100644 --- a/nanobot/cli_apps/utils.py +++ b/nanobot/apps/cli/utils.py @@ -46,7 +46,7 @@ def _cli_app_runtime_lines( if "@" not in text: return [] try: - from nanobot.cli_apps import CliAppManager + from nanobot.apps.cli import CliAppManager mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text) except Exception: diff --git a/nanobot/apps/protocol.py b/nanobot/apps/protocol.py new file mode 100644 index 000000000..02b1e55bd --- /dev/null +++ b/nanobot/apps/protocol.py @@ -0,0 +1,56 @@ +"""Neutral manifest shape for settings-managed agent apps. + +The manifest is intentionally descriptive. Installers still live in their +own adapters, while this protocol gives the WebUI and future registries one +small vocabulary for capabilities, trust, and verified install/remove plans. +""" + +from __future__ import annotations + +from typing import Any + +APP_PROTOCOL_SCHEMA = "agent-app.v1" + + +def compact_dict(values: dict[str, Any]) -> dict[str, Any]: + """Drop empty optional values while preserving explicit booleans and zeros.""" + return { + key: value + for key, value in values.items() + if value is not None and value != "" and value != [] and value != {} + } + + +def app_manifest( + *, + app_id: str, + display_name: str, + description: str, + category: str, + source: str, + capabilities: list[dict[str, Any]], + install: dict[str, Any], + remove: dict[str, Any], + trust: dict[str, Any], + version: str | None = None, + logo_url: str | None = None, + brand_color: str | None = None, + docs_url: str | None = None, +) -> dict[str, Any]: + """Build a stable app manifest dictionary.""" + return compact_dict({ + "schema": APP_PROTOCOL_SCHEMA, + "id": app_id, + "display_name": display_name, + "version": version, + "description": description, + "category": category, + "source": source, + "logo_url": logo_url, + "brand_color": brand_color, + "docs_url": docs_url, + "capabilities": capabilities, + "install": install, + "remove": remove, + "trust": trust, + }) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index e0808e107..86f439cd8 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -10,10 +10,11 @@ import pydantic from loguru import logger from pydantic import BaseModel -from nanobot.config.schema import Config +from nanobot.config.schema import Config, _resolve_tool_config_refs # Global variable to store current config path (for multi-instance support) _current_config_path: Path | None = None +_schema_refs_ready = False def set_config_path(path: Path) -> None: @@ -39,6 +40,11 @@ def load_config(config_path: Path | None = None) -> Config: Returns: Loaded configuration object. """ + global _schema_refs_ready + if not _schema_refs_ready: + _resolve_tool_config_refs() + _schema_refs_ready = True + path = config_path or get_config_path() config = Config() diff --git a/nanobot/webui/cli_apps_api.py b/nanobot/webui/cli_apps_api.py index 86e8e715e..1e6fdfeae 100644 --- a/nanobot/webui/cli_apps_api.py +++ b/nanobot/webui/cli_apps_api.py @@ -5,7 +5,7 @@ from __future__ import annotations import re from typing import Any -from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig +from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig from nanobot.config.loader import load_config QueryParams = dict[str, list[str]] diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index d87747d8a..40a799c1a 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -16,6 +16,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Literal, Mapping +from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.agent.tools.registry import ToolRegistry from nanobot.config.loader import load_config, resolve_config_env_vars, save_config from nanobot.config.paths import get_runtime_subdir @@ -475,6 +476,20 @@ def _with_managed_stdio_cwd(name: str, cfg: MCPServerConfig) -> MCPServerConfig: return cfg +def _remove_managed_stdio_cwd(name: str, cfg: MCPServerConfig | None) -> bool: + if cfg is None or not cfg.cwd: + return False + cwd = Path(cfg.cwd).expanduser().resolve(strict=False) + managed = (get_runtime_subdir("mcp") / name).resolve(strict=False) + if cwd != managed or not cwd.exists(): + return False + if cwd.is_symlink() or cwd.is_file(): + cwd.unlink() + else: + shutil.rmtree(cwd) + return True + + def _url_with_param(url: str, key: str, value: str) -> str: parsed = urllib.parse.urlsplit(url) query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) @@ -647,10 +662,108 @@ def _tool_allowlist(cfg: MCPServerConfig | None) -> list[str]: return list(cfg.enabled_tools) +def _managed_mcp_path(name: str, cfg: MCPServerConfig | None) -> list[str]: + if cfg is None or not cfg.command: + return [] + return [f"runtime:mcp/{name}"] + + +def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]: + server = preset.server + managed_paths = _managed_mcp_path(preset.name, server) + field_specs = [ + compact_dict({ + "name": field.name, + "target": field.target[0], + "required": field.required, + "secret": field.secret, + "env_var": field.env_var, + }) + for field in preset.fields + ] + capabilities = [ + compact_dict({ + "type": "mcp", + "transport": preset.transport, + "command": server.command if server and server.command else None, + "args": list(server.args) if server and server.command else None, + "url": _connection_summary(server) if server and server.url else None, + "fields": field_specs, + }) + ] + return app_manifest( + app_id=preset.name, + display_name=preset.display_name, + description=preset.description, + category=preset.category, + source="mcp-preset", + docs_url=preset.docs_url, + logo_url=logo_url, + brand_color=preset.brand_color, + capabilities=capabilities, + install=compact_dict({ + "supported": preset.install_supported, + "strategy": "config", + "managed_paths": managed_paths, + "verification": ["config_present", "dependency_available"], + }), + remove=compact_dict({ + "supported": True, + "strategy": "config", + "managed_paths": managed_paths, + "verification": ["config_absent", "managed_paths_absent"] if managed_paths else ["config_absent"], + }), + trust={ + "registry": "mcp-presets", + "level": "builtin", + "review_status": "builtin_preset", + }, + ) + + +def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]: + transport = cfg.type or ("stdio" if cfg.command else "streamableHttp") + managed_paths: list[str] = [] + return app_manifest( + app_id=name, + display_name=name, + description="Custom MCP server from nanobot config.", + category="custom", + source="mcp-custom", + brand_color="#64748B", + capabilities=[ + compact_dict({ + "type": "mcp", + "transport": transport, + "command": cfg.command or None, + "url": _connection_summary(cfg) if cfg.url else None, + }) + ], + install=compact_dict({ + "supported": True, + "strategy": "config", + "managed_paths": managed_paths, + "verification": ["config_present", "dependency_available"], + }), + remove=compact_dict({ + "supported": True, + "strategy": "config", + "managed_paths": managed_paths, + "verification": ["config_absent", "managed_paths_absent"] if managed_paths else ["config_absent"], + }), + trust={ + "registry": "user-config", + "level": "user", + "review_status": "user_managed", + }, + ) + + def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]: cfg = configured_servers.get(preset.name) status = _status_for(preset, cfg) configured = cfg is not None and status not in {"missing_credentials"} + logo_url = _favicon_url(preset.brand_domain) return { "name": preset.name, "display_name": preset.display_name, @@ -665,12 +778,13 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo "configured": configured, "available": configured and _config_available(cfg), "status": status, - "logo_url": _favicon_url(preset.brand_domain), + "logo_url": logo_url, "brand_color": preset.brand_color, "required_fields": [_field_payload(field, cfg) for field in preset.fields], "connection_summary": _connection_summary(cfg), "enabled_tools": _tool_allowlist(cfg), "source": "preset", + "manifest": _preset_manifest(preset, logo_url=logo_url), } @@ -705,6 +819,7 @@ def _custom_payload( "enabled_tools": _tool_allowlist(cfg), "tool_names": tool_names or [], "source": "custom", + "manifest": _custom_manifest(name, cfg), } @@ -744,10 +859,17 @@ def _action_message(action: str, preset: McpPreset, *, ok: bool = True) -> dict[ "remove": "Removed", "test": "Checked", }.get(action, "Updated") - return { + payload: dict[str, Any] = { "ok": ok, "message": f"{verb} MCP preset for {preset.display_name}.", } + if action == "enable": + payload["installed"] = True + payload["verification"] = ["config_present"] + elif action == "remove": + payload["removed"] = True + payload["verification"] = ["config_absent"] + return payload def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[str, Any]: @@ -758,10 +880,17 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s "tools": "Updated tools for", "remove": "Removed", }.get(action, "Updated") - return { + payload: dict[str, Any] = { "ok": ok, "message": f"{verb} MCP server {name}.", } + if action in {"custom", "import", "import-cursor"}: + payload["installed"] = True + payload["verification"] = ["config_present"] + elif action == "remove": + payload["removed"] = True + payload["verification"] = ["config_absent"] + return payload def _scrub_test_error(text: str) -> str: @@ -1113,7 +1242,14 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]: if action == "remove": if preset is None and name not in config.tools.mcp_servers: raise McpPresetError("unknown MCP server", status=404) + removed_runtime_files = False + cleanup_error = "" if name in config.tools.mcp_servers: + existing_cfg = config.tools.mcp_servers[name] + try: + removed_runtime_files = _remove_managed_stdio_cwd(name, existing_cfg) + except OSError as exc: + cleanup_error = str(exc) del config.tools.mcp_servers[name] save_config(config) last_action = ( @@ -1121,6 +1257,16 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]: if preset is not None else _server_action_message(action, name) ) + if removed_runtime_files: + last_action["message"] = f"{last_action['message']} Removed managed runtime files." + last_action["managed_paths_removed"] = [f"runtime:mcp/{name}"] + last_action["verification"] = ["config_absent", "managed_paths_absent"] + if cleanup_error: + last_action["ok"] = False + last_action["message"] = ( + f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}" + ) + last_action["verification_failed"] = ["managed_paths_absent"] payload = mcp_presets_payload(last_action=last_action) payload["requires_restart"] = True return payload diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index fda0199b5..0c42505f4 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -5,10 +5,11 @@ import subprocess import sys import time from pathlib import Path +from types import SimpleNamespace import pytest -from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig +from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig def _write_cache(path: Path, registry: dict) -> None: @@ -146,6 +147,15 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) - assert apps["jimeng"]["install_supported"] is False assert apps["suno"]["install_supported"] is True assert apps["gimp"]["logo_url"] + gimp_manifest = apps["gimp"]["manifest"] + assert gimp_manifest["schema"] == "agent-app.v1" + assert gimp_manifest["id"] == "gimp" + assert gimp_manifest["source"] == "cli-anything:harness+public" + assert gimp_manifest["capabilities"][0]["type"] == "cli" + assert gimp_manifest["capabilities"][0]["entry_point"] == "cli-anything-gimp" + assert gimp_manifest["install"]["verification"] == ["entry_point_available"] + assert "entry_point_absent" in gimp_manifest["remove"]["verification"] + assert gimp_manifest["trust"]["review_status"] == "catalog_entry" assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF" assert apps["feishu"]["logo_url"] == ( "https://www.google.com/s2/favicons?domain=larksuite.com&sz=64" @@ -156,6 +166,33 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) - ) +def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None: + manager = _manager(tmp_path) + _write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []}) + _write_cache( + manager._cache_path("public"), + { + "meta": {"updated": "2026-04-18"}, + "clis": [ + { + "name": "anygen", + "display_name": "AnyGen", + "description": "Generate docs, slides, websites and more via AnyGen cloud API", + "category": "generation", + "install_cmd": "pip install cli-anything-anygen", + "entry_point": "cli-anything-anygen", + } + ], + }, + ) + + payload = manager.payload() + + app = payload["apps"][0] + assert app["name"] == "anygen" + assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64" + + def test_install_dispatches_safe_pip_and_installs_skill( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -179,6 +216,8 @@ def test_install_dispatches_safe_pip_and_installs_skill( assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]] assert payload["last_action"]["ok"] is True + assert payload["last_action"]["installed"] is True + assert "state_recorded" in payload["last_action"]["verification"] installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] assert installed["gimp"]["entry_point"] == "cli-anything-gimp" skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md" @@ -186,6 +225,49 @@ def test_install_dispatches_safe_pip_and_installs_skill( assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8") +def test_install_records_entry_point_path_and_pip_distribution( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _manager(tmp_path) + _seed_catalog(manager) + resolved = tmp_path / "bin" / "cli-anything-gimp" + resolved.parent.mkdir() + resolved.write_text("#!/bin/sh\n", encoding="utf-8") + + monkeypatch.setattr( + manager, + "_run_argv", + lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""), + ) + monkeypatch.setattr( + manager, + "_fetch_skill_content", + lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n", + ) + monkeypatch.setattr( + "nanobot.apps.cli.service.shutil.which", + lambda command: str(resolved) if command == "cli-anything-gimp" else None, + ) + monkeypatch.setattr( + "nanobot.apps.cli.service.importlib_metadata.distributions", + lambda: [ + SimpleNamespace( + entry_points=[ + SimpleNamespace(group="console_scripts", name="cli-anything-gimp"), + ], + metadata={"Name": "cli-anything-gimp"}, + ) + ], + ) + + manager.install("gimp") + + installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] + assert installed["gimp"]["entry_point_path"] == str(resolved) + assert installed["gimp"]["pip_distribution"] == "cli-anything-gimp" + + def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None: manager = _manager(tmp_path) @@ -206,7 +288,7 @@ def test_fetch_skill_content_rejects_untrusted_urls( def fail_get(*args, **kwargs): raise AssertionError("untrusted skill URL should not be fetched") - monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get) + monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get) assert manager._fetch_skill_content({ "name": "evil", @@ -236,7 +318,7 @@ def test_fetch_skill_content_allows_cli_anything_raw_skill_url( seen.append(url) return Response() - monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get) + monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get) content = manager._fetch_skill_content({ "name": "gimp", @@ -293,6 +375,91 @@ def test_uninstall_uses_safe_python_m_pip_uninstall_command( assert payload["last_action"]["ok"] is True +def test_uninstall_uses_recorded_pip_distribution( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _manager(tmp_path) + _seed_catalog(manager) + manager._save_installed({ + "gimp": { + "entry_point": "cli-anything-gimp", + "pip_distribution": "actual-dist-name", + "entry_point_path": str(tmp_path / "bin" / "cli-anything-gimp"), + } + }) + calls: list[list[str]] = [] + + def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]: + calls.append(argv) + return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="") + + monkeypatch.setattr(manager, "_run_argv", fake_run) + + payload = manager.uninstall("gimp") + + assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "actual-dist-name"]] + assert payload["last_action"]["ok"] is True + assert payload["last_action"]["removed"] is True + assert "entry_point_absent" in payload["last_action"]["verification"] + assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] + + +def test_uninstall_keeps_state_when_entry_point_still_available( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _manager(tmp_path) + _seed_catalog(manager) + manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}}) + monkeypatch.setattr( + manager, + "_run_argv", + lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""), + ) + monkeypatch.setattr( + "nanobot.apps.cli.service.shutil.which", + lambda command: "/usr/local/bin/cli-anything-gimp" if command == "cli-anything-gimp" else None, + ) + + payload = manager.uninstall("gimp") + + assert payload["last_action"]["ok"] is False + assert payload["last_action"]["removed"] is False + assert payload["last_action"]["still_available"] is True + assert payload["last_action"]["verification_failed"] == ["entry_point_absent"] + assert "kept it installed" in payload["last_action"]["message"] + assert "gimp" in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] + + +def test_uninstall_keeps_state_when_recorded_entry_point_still_exists( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _manager(tmp_path) + _seed_catalog(manager) + resolved = tmp_path / "bin" / "cli-anything-gimp" + resolved.parent.mkdir() + resolved.write_text("#!/bin/sh\n", encoding="utf-8") + manager._save_installed({ + "gimp": { + "entry_point": "cli-anything-gimp", + "entry_point_path": str(resolved), + } + }) + monkeypatch.setattr( + manager, + "_run_argv", + lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""), + ) + + payload = manager.uninstall("gimp") + + assert payload["last_action"]["ok"] is False + assert str(resolved) in payload["last_action"]["message"] + assert "gimp" in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] + + def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None: manager = _manager(tmp_path) manager._save_installed( @@ -341,7 +508,7 @@ def test_run_installed_cli_uses_argv_without_shell( _seed_catalog(manager) resolved = str(tmp_path / "bin" / "cli-anything-gimp") monkeypatch.setattr( - "nanobot.cli_apps.service.shutil.which", + "nanobot.apps.cli.service.shutil.which", lambda entry: resolved if entry == "cli-anything-gimp" else None, ) @@ -354,7 +521,7 @@ def test_run_installed_cli_uses_argv_without_shell( stderr="", ) - monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run) + monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run) manager._save_installed( { "gimp": { @@ -380,7 +547,7 @@ def test_run_reports_created_artifacts( _seed_catalog(manager) resolved = str(tmp_path / "bin" / "cli-anything-gimp") monkeypatch.setattr( - "nanobot.cli_apps.service.shutil.which", + "nanobot.apps.cli.service.shutil.which", lambda entry: resolved if entry == "cli-anything-gimp" else None, ) @@ -389,7 +556,7 @@ def test_run_reports_created_artifacts( (cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage") return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="") - monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run) + monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run) manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}}) result = manager.run("gimp", ["render"]) diff --git a/tests/cli_apps/test_tool.py b/tests/cli_apps/test_tool.py index d75f110ac..e0b3b47c6 100644 --- a/tests/cli_apps/test_tool.py +++ b/tests/cli_apps/test_tool.py @@ -7,7 +7,7 @@ import time from pathlib import Path from nanobot.agent.tools.cli_apps import CliAppsTool -from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig +from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig def _write_cache(path: Path, registry: dict) -> None: @@ -46,7 +46,7 @@ def test_run_cli_app_uses_installed_registry_app( ) resolved = str(tmp_path / "bin" / "cli-anything-gimp") monkeypatch.setattr( - "nanobot.cli_apps.service.shutil.which", + "nanobot.apps.cli.service.shutil.which", lambda entry: resolved if entry == "cli-anything-gimp" else None, ) @@ -59,8 +59,8 @@ def test_run_cli_app_uses_installed_registry_app( stderr="", ) - monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run) - monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir) + monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run) + monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir) tool = CliAppsTool( workspace=workspace, @@ -102,7 +102,7 @@ def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> Non } _write_cache(data_dir / "harness_registry_cache.json", registry) _write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []}) - monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir) + monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir) tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True) result = asyncio.run(tool.execute(name="gimp")) @@ -117,7 +117,7 @@ def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Pa CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed( {"drawio": {"entry_point": "cli-anything-drawio"}} ) - monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir) + monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir) tool = CliAppsTool(workspace=workspace) diff --git a/tests/cli_apps/test_utils.py b/tests/cli_apps/test_utils.py index 9be2116a6..2a2b01d0e 100644 --- a/tests/cli_apps/test_utils.py +++ b/tests/cli_apps/test_utils.py @@ -2,8 +2,8 @@ from types import SimpleNamespace -from nanobot.cli_apps.service import CliAppManager -from nanobot.cli_apps.utils import runtime_lines, session_extra +from nanobot.apps.cli.service import CliAppManager +from nanobot.apps.cli.utils import runtime_lines, session_extra def test_session_extra_returns_cli_apps_only_when_present() -> None: @@ -15,7 +15,7 @@ def test_session_extra_returns_cli_apps_only_when_present() -> None: def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch): data_dir = tmp_path / "data" - monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir) + monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir) manager = CliAppManager(workspace=tmp_path) manager._save_installed( { diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index 6ddde0b47..471aee959 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -43,6 +43,15 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest assert browserbase["install_supported"] is True assert browserbase["required_fields"][0]["configured"] is False assert "browserbaseApiKey" not in browserbase["connection_summary"] + manifest = browserbase["manifest"] + assert manifest["schema"] == "agent-app.v1" + assert manifest["id"] == "browserbase" + assert manifest["source"] == "mcp-preset" + assert manifest["capabilities"][0]["type"] == "mcp" + assert manifest["capabilities"][0]["transport"] == "streamableHttp" + assert manifest["install"]["strategy"] == "config" + assert manifest["remove"]["verification"] == ["config_absent"] + assert manifest["trust"]["review_status"] == "builtin_preset" def test_enable_browserbase_writes_scrubbed_config_payload( @@ -61,6 +70,8 @@ def test_enable_browserbase_writes_scrubbed_config_payload( assert payload["requires_restart"] is True assert payload["last_action"]["ok"] is True + assert payload["last_action"]["installed"] is True + assert payload["last_action"]["verification"] == ["config_present"] preset = next(row for row in payload["presets"] if row["name"] == "browserbase") assert preset["installed"] is True assert preset["configured"] is True @@ -149,14 +160,43 @@ def test_enable_firecrawl_writes_scrubbed_env(tmp_path, monkeypatch: pytest.Monk def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: _use_config(tmp_path, monkeypatch) mcp_presets_action("enable", {"name": ["playwright"]}) + managed_cwd = tmp_path / "mcp" / "playwright" + (managed_cwd / "cache.txt").write_text("managed runtime data", encoding="utf-8") payload = mcp_presets_action("remove", {"name": ["playwright"]}) assert payload["requires_restart"] is True + assert payload["last_action"]["ok"] is True + assert payload["last_action"]["removed"] is True + assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"] + assert not managed_cwd.exists() config = load_config() assert "playwright" not in config.tools.mcp_servers +def test_remove_custom_mcp_server_preserves_user_cwd(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + _use_config(tmp_path, monkeypatch) + user_cwd = tmp_path / "user-cwd" + user_cwd.mkdir() + custom_mcp_action( + "custom", + { + "name": ["internal-docs"], + "transport": ["stdio"], + "command": ["node"], + "args": ['["server.js"]'], + "cwd": [str(user_cwd)], + }, + ) + + payload = mcp_presets_action("remove", {"name": ["internal-docs"]}) + + assert payload["last_action"]["ok"] is True + assert user_cwd.exists() + config = load_config() + assert "internal-docs" not in config.tools.mcp_servers + + def test_test_mcp_preset_reports_missing_dependency( tmp_path, monkeypatch: pytest.MonkeyPatch, @@ -282,6 +322,10 @@ def test_custom_mcp_server_writes_config_and_catalog_row( assert row["source"] == "custom" assert row["transport"] == "stdio" assert row["connection_summary"] == "node server.js" + assert row["manifest"]["schema"] == "agent-app.v1" + assert row["manifest"]["source"] == "mcp-custom" + assert row["manifest"]["capabilities"][0]["command"] == "node" + assert "server.js" not in str(row["manifest"]) assert "docs-secret-value" not in str(payload) config = load_config() assert config.tools.mcp_servers["internal-docs"].args == ["server.js"] diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8c6127829..f1d4d9100 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -4,7 +4,7 @@ import { DeleteConfirm } from "@/components/DeleteConfirm"; import { RenameChatDialog } from "@/components/RenameChatDialog"; import { Sidebar } from "@/components/Sidebar"; import { SessionSearchDialog } from "@/components/SessionSearchDialog"; -import { SettingsView } from "@/components/settings/SettingsView"; +import { SettingsView, type SettingsSectionKey } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; @@ -46,7 +46,7 @@ const SIDEBAR_WIDTH = 272; const SIDEBAR_RAIL_WIDTH = 56; const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; -type ShellView = "chat" | "settings"; +type ShellView = "chat" | "settings" | "apps"; function bootstrapTokenExpiresAt(expiresInSeconds: number): number { return Date.now() + Math.max(0, expiresInSeconds) * 1000; @@ -325,6 +325,7 @@ function Shell({ useSidebarState(sessions, !loading); const [activeKey, setActiveKey] = useState(null); const [view, setView] = useState("chat"); + const [settingsInitialSection, setSettingsInitialSection] = useState("overview"); const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(readSidebarOpen); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -588,12 +589,20 @@ function Shell({ [onSelectChat], ); - const onOpenSettings = useCallback(() => { + const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => { setSessionSearchOpen(false); + setSettingsInitialSection(section); setView("settings"); setMobileSidebarOpen(false); }, []); + const onOpenApps = useCallback(() => { + setSessionSearchOpen(false); + setSettingsInitialSection("apps"); + setView("apps"); + setMobileSidebarOpen(false); + }, []); + const onBackToChat = useCallback(() => { setView("chat"); setMobileSidebarOpen(false); @@ -711,6 +720,12 @@ function Shell({ }); return; } + if (view === "apps") { + document.title = t("app.documentTitle.chat", { + title: t("settings.nav.apps", { defaultValue: "Apps" }), + }); + return; + } document.title = activeSession ? t("app.documentTitle.chat", { title: headerTitle }) : t("app.documentTitle.base"); @@ -728,7 +743,9 @@ function Shell({ onRequestRename, onToggleArchive, onOpenSettings, + onOpenApps, onOpenSearch: onOpenSessionSearch, + activeUtility: view === "apps" ? "apps" as const : null, onToggleArchived, onUpdateView: onUpdateSidebarView, pinnedKeys: sidebarState.pinned_keys, @@ -805,7 +822,7 @@ function Shell({
- {view === "settings" && ( + {view !== "chat" && (
void; onToggleArchive: (key: string) => void; onOpenSettings: () => void; + onOpenApps: () => void; onOpenSearch: () => void; + activeUtility?: "apps" | null; onToggleArchived: () => void; onUpdateView: (view: Partial) => void; onCollapse: () => void; @@ -129,6 +132,13 @@ export function Sidebar(props: SidebarProps) { onClick={props.onOpenSearch} icon={} /> + } + /> void; + active?: boolean; className?: string; }) { return ( @@ -214,14 +226,16 @@ function SidebarActionButton({ type="button" variant="ghost" aria-label={label} + aria-current={active ? "page" : undefined} title={collapsed ? label : undefined} - onClick={onClick} + onClick={() => onClick()} className={cn( "group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground", "transition-[width,padding,border-radius,color,background-color] duration-300 ease-out", collapsed ? "w-9 justify-center gap-0 rounded-xl px-0" : "w-full justify-start gap-2 px-3 text-[12.5px]", + active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", className, )} > diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 1fe379f6a..cbaa18ddd 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + forwardRef, useMemo, useState, type Dispatch, @@ -31,7 +32,6 @@ import { Loader2, LogOut, Moon, - Package, PlayCircle, Plus, Orbit, @@ -43,9 +43,11 @@ import { ShieldCheck, SlidersHorizontal, Sparkles, + Blocks, Trash2, Triangle, Waves, + X, Zap, type LucideIcon, } from "lucide-react"; @@ -103,19 +105,22 @@ import type { WebSearchSettingsUpdate, } from "@/lib/types"; -type SettingsSectionKey = +export type SettingsSectionKey = | "overview" | "appearance" | "models" | "image" | "web" - | "cliApps" - | "mcp" + | "apps" | "runtime" | "advanced"; type LocalDensity = "comfortable" | "compact"; type LocalActivityMode = "auto" | "expanded"; +type AppsKindFilter = "all" | "cli" | "mcp"; +type AppsCatalogItem = + | { id: string; kind: "cli"; app: CliAppInfo } + | { id: string; kind: "mcp"; preset: McpPresetInfo }; interface LocalPreferences { density: LocalDensity; @@ -225,6 +230,8 @@ const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = { interface SettingsViewProps { theme: "light" | "dark"; + initialSection?: SettingsSectionKey; + showSidebar?: boolean; onToggleTheme: () => void; onBackToChat: () => void; onModelNameChange: (modelName: string | null) => void; @@ -264,6 +271,8 @@ function editableDefaultProvider(payload: SettingsPayload): string { export function SettingsView({ theme, + initialSection = "overview", + showSidebar = true, onToggleTheme, onBackToChat, onModelNameChange, @@ -293,18 +302,14 @@ export function SettingsView({ const [webSearchSaving, setWebSearchSaving] = useState(false); const [imageGenerationSaving, setImageGenerationSaving] = useState(false); const [error, setError] = useState(null); - const [activeSection, setActiveSection] = useState("overview"); + const [activeSection, setActiveSection] = useState(initialSection); const [expandedProvider, setExpandedProvider] = useState(null); const [providerQuery, setProviderQuery] = useState(""); - const [cliAppsQuery, setCliAppsQuery] = useState(""); - const [cliAppsCategory, setCliAppsCategory] = useState("all"); - const [cliAppsInstallFilter, setCliAppsInstallFilter] = useState<"all" | "installed" | "notInstalled">("all"); + const [appsQuery, setAppsQuery] = useState(""); const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); - const [mcpQuery, setMcpQuery] = useState(""); - const [mcpCategory, setMcpCategory] = useState("all"); - const [mcpInstallFilter, setMcpInstallFilter] = useState<"all" | "installed" | "notInstalled">("all"); + const [appsKindFilter, setAppsKindFilter] = useState("all"); const [mcpMessage, setMcpMessage] = useState(null); const [mcpError, setMcpError] = useState(null); const [mcpFieldValues, setMcpFieldValues] = useState>>({}); @@ -333,6 +338,10 @@ export function SettingsView({ defaultImageSize: "1K", maxImagesPerTurn: 4, }); + + useEffect(() => { + setActiveSection(initialSection); + }, [initialSection]); const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); const [form, setForm] = useState({ @@ -925,6 +934,8 @@ export function SettingsView({ onRestart={onRestart} isRestarting={isRestarting} showBrandLogos={localPrefs.brandLogos} + cliApps={cliApps} + mcpPresets={mcpPresets} onSelectSection={setActiveSection} /> ); @@ -1022,48 +1033,39 @@ export function SettingsView({ requiresRestartPending={pendingRestartSections.web} /> ); - case "cliApps": + case "apps": return ( - - ); - case "mcp": - return ( - { + onQueryChange={setAppsQuery} + onFilterChange={setAppsKindFilter} + onCliAction={handleCliAppAction} + onMcpAction={handleMcpPresetAction} + onDismissStatus={() => { + setCliAppsMessage(null); + setCliAppsError(null); + setMcpMessage(null); + setMcpError(null); + }} + onBackToChat={onBackToChat} + onMcpFieldChange={(presetName, fieldName, value) => { setMcpFieldValues((prev) => ({ ...prev, [presetName]: { @@ -1072,10 +1074,11 @@ export function SettingsView({ }, })); }} - onAction={handleMcpPresetAction} - onSaveCustom={handleSaveCustomMcp} - onImportConfig={handleImportMcpConfig} - onToolsChange={handleMcpToolsChange} + onCustomMcpFormChange={setCustomMcpForm} + onMcpConfigImportChange={setMcpConfigImport} + onSaveCustomMcp={handleSaveCustomMcp} + onImportMcpConfig={handleImportMcpConfig} + onMcpToolsChange={handleMcpToolsChange} onRestart={onRestart} isRestarting={isRestarting} /> @@ -1103,12 +1106,14 @@ export function SettingsView({ return (
- + {showSidebar ? ( + + ) : null} void; showBrandLogos: boolean; + cliApps: CliAppsPayload | null; + mcpPresets: McpPresetsPayload | null; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); @@ -1276,6 +1284,14 @@ function OverviewSettings({ ? tx("settings.values.configured", "Configured") : tx("settings.values.notConfigured", "Not configured") }`; + const cliAppsEnabledCount = cliApps?.installed_count ?? 0; + const mcpAppsEnabledCount = mcpPresets?.installed_count ?? settings.advanced.mcp_server_count; + const appsCount = cliAppsEnabledCount + mcpAppsEnabledCount; + const appsValue = tx("settings.apps.enabledSummary", "{{count}} enabled") + .replace("{{count}}", String(appsCount)); + const appsCaption = tx("settings.apps.caption", "{{cli}} CLI · {{mcp}} MCP") + .replace("{{cli}}", String(cliAppsEnabledCount)) + .replace("{{mcp}}", String(mcpAppsEnabledCount)); return (
@@ -1356,6 +1372,13 @@ function OverviewSettings({ showBrandLogos={showBrandLogos} onClick={() => onSelectSection("image")} /> + onSelectSection("apps")} + />
@@ -2379,316 +2402,168 @@ function WebSettings({ ); } -function CliAppsSettings({ - payload, - loading, +function AppsCatalogSettings({ + cliApps, + mcpPresets, + cliAppsLoading, + mcpPresetsLoading, query, - category, - installFilter, - actionKey, - message, - error, - focusName, - showBrandLogos, - onQueryChange, - onCategoryChange, - onInstallFilterChange, - onAction, - onBackToChat, -}: { - payload: CliAppsPayload | null; - loading: boolean; - query: string; - category: string; - installFilter: "all" | "installed" | "notInstalled"; - actionKey: string | null; - message: string | null; - error: string | null; - focusName: string | null; - showBrandLogos: boolean; - onQueryChange: (value: string) => void; - onCategoryChange: (value: string) => void; - onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void; - onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; - onBackToChat: () => void; -}) { - const { t } = useTranslation(); - const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const apps = payload?.apps ?? []; - const categories = useMemo( - () => ["all", ...Array.from(new Set(apps.map((app) => app.category))).sort()], - [apps], - ); - const normalizedQuery = query.trim().toLowerCase(); - const filteredApps = apps.filter((app) => { - const categoryMatch = category === "all" || app.category === category; - if (!categoryMatch) return false; - if (installFilter === "installed" && !app.installed) return false; - if (installFilter === "notInstalled" && app.installed) return false; - if (!normalizedQuery) return true; - return ( - app.display_name.toLowerCase().includes(normalizedQuery) || - app.name.toLowerCase().includes(normalizedQuery) || - app.description.toLowerCase().includes(normalizedQuery) || - app.category.toLowerCase().includes(normalizedQuery) - ); - }); - const categoryLabel = - category === "all" - ? tx("settings.cliApps.allCategories", "All categories") - : category; - const installFilterOptions = [ - { value: "all", label: tx("settings.cliApps.filterAll", "All") }, - { value: "installed", label: tx("settings.cliApps.filterInstalled", "Installed CLIs") }, - { value: "notInstalled", label: tx("settings.cliApps.filterNotInstalled", "Not installed") }, - ]; - const focusedApp = focusName - ? apps.find((app) => app.name === focusName && app.installed) - : null; - const visibleStatusMessage = error || (!focusedApp ? message : null); - - return ( -
-
-
-
- {tx("settings.sections.cliApps", "CLI Apps")} -

- {tx("settings.cliApps.summary", "{{installed}} of {{total}} CLIs installed") - .replace("{{installed}}", String(payload?.installed_count ?? 0)) - .replace("{{total}}", String(apps.length))} -

-
- onInstallFilterChange(value as "all" | "installed" | "notInstalled")} - /> -
- -
-
- - onQueryChange(event.target.value)} - placeholder={tx("settings.cliApps.searchPlaceholder", "Search CLIs")} - className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]" - /> -
- - - - - - {categories.map((item) => ( - onCategoryChange(item)}> - {item === "all" ? tx("settings.cliApps.allCategories", "All categories") : item} - - ))} - - -
-
- - {visibleStatusMessage ? ( -
- {visibleStatusMessage} -
- ) : null} - - {focusedApp ? ( - - ) : null} - - {loading ? ( -
- - {tx("settings.cliApps.loading", "Loading CLI Apps...")} -
- ) : ( -
-
- {filteredApps.map((app) => ( - - ))} -
- {!filteredApps.length ? ( -
- {tx("settings.cliApps.empty", "No CLI Apps match this filter.")} -
- ) : null} -
- )} - -
- ); -} - -function McpPresetsSettings({ - payload, - loading, - query, - category, - installFilter, - actionKey, - message, - error, - fieldValues, - customForm, - configImport, + filter, + cliActionKey, + mcpActionKey, + cliMessage, + cliError, + cliFocusName, + mcpMessage, + mcpError, + mcpFieldValues, + customMcpForm, + mcpConfigImport, showBrandLogos, requiresRestartPending, onQueryChange, - onCategoryChange, - onInstallFilterChange, - onCustomFormChange, - onConfigImportChange, - onFieldChange, - onAction, - onSaveCustom, - onImportConfig, - onToolsChange, + onFilterChange, + onCliAction, + onMcpAction, + onDismissStatus, + onBackToChat, + onMcpFieldChange, + onCustomMcpFormChange, + onMcpConfigImportChange, + onSaveCustomMcp, + onImportMcpConfig, + onMcpToolsChange, onRestart, isRestarting, }: { - payload: McpPresetsPayload | null; - loading: boolean; + cliApps: CliAppsPayload | null; + mcpPresets: McpPresetsPayload | null; + cliAppsLoading: boolean; + mcpPresetsLoading: boolean; query: string; - category: string; - installFilter: "all" | "installed" | "notInstalled"; - actionKey: string | null; - message: string | null; - error: string | null; - fieldValues: Record>; - customForm: CustomMcpForm; - configImport: string; + filter: AppsKindFilter; + cliActionKey: string | null; + mcpActionKey: string | null; + cliMessage: string | null; + cliError: string | null; + cliFocusName: string | null; + mcpMessage: string | null; + mcpError: string | null; + mcpFieldValues: Record>; + customMcpForm: CustomMcpForm; + mcpConfigImport: string; showBrandLogos: boolean; requiresRestartPending: boolean; onQueryChange: (value: string) => void; - onCategoryChange: (value: string) => void; - onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void; - onCustomFormChange: Dispatch>; - onConfigImportChange: (value: string) => void; - onFieldChange: (presetName: string, fieldName: string, value: string) => void; - onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; - onSaveCustom: () => void; - onImportConfig: () => void; - onToolsChange: (name: string, enabledTools: string[]) => void; + onFilterChange: (value: AppsKindFilter) => void; + onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; + onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; + onDismissStatus: () => void; + onBackToChat: () => void; + onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void; + onCustomMcpFormChange: Dispatch>; + onMcpConfigImportChange: (value: string) => void; + onSaveCustomMcp: () => void; + onImportMcpConfig: () => void; + onMcpToolsChange: (name: string, enabledTools: string[]) => void; onRestart?: () => void; isRestarting?: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const presets = payload?.presets ?? []; - const categories = useMemo( - () => ["all", ...Array.from(new Set(presets.map((preset) => preset.category))).sort()], - [presets], - ); - const normalizedQuery = query.trim().toLowerCase(); - const filteredPresets = presets.filter((preset) => { - const categoryMatch = category === "all" || preset.category === category; - if (!categoryMatch) return false; - if (installFilter === "installed" && !preset.installed) return false; - if (installFilter === "notInstalled" && preset.installed) return false; - if (!normalizedQuery) return true; - return ( - preset.display_name.toLowerCase().includes(normalizedQuery) || - preset.name.toLowerCase().includes(normalizedQuery) || - preset.description.toLowerCase().includes(normalizedQuery) || - preset.category.toLowerCase().includes(normalizedQuery) - ); - }); - const installFilterOptions = [ - { value: "all", label: tx("settings.mcp.filterAll", "All") }, - { value: "installed", label: tx("settings.mcp.filterInstalled", "Enabled") }, - { value: "notInstalled", label: tx("settings.mcp.filterNotInstalled", "Not enabled") }, + const filterOptions = [ + { value: "all", label: tx("settings.apps.filterAll", "All") }, + { value: "cli", label: tx("settings.apps.filterCli", "App CLIs") }, + { value: "mcp", label: tx("settings.apps.filterMcp", "MCP services") }, ]; - const categoryLabel = category === "all" ? tx("settings.mcp.allCategories", "All categories") : category; - const visibleStatusMessage = error || message; - const testToolNames = payload?.last_action?.tool_names ?? []; - const testToolCount = payload?.last_action?.tool_count; - const showTestDetails = typeof testToolCount === "number" || testToolNames.length > 0 || !!payload?.last_action?.error; + const normalizedQuery = query.trim().toLowerCase(); + const items: AppsCatalogItem[] = [ + ...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })), + ...(mcpPresets?.presets ?? []).map((preset) => ({ + id: `mcp:${preset.name}`, + kind: "mcp" as const, + preset, + })), + ] + .filter((item) => filter === "all" || item.kind === filter) + .filter((item) => !normalizedQuery || appsSearchText(item).includes(normalizedQuery)) + .sort((left, right) => { + const rank = Number(!appsReady(left)) - Number(!appsReady(right)); + return rank || appsTitle(left).localeCompare(appsTitle(right)); + }); + const focusedApp = cliFocusName + ? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed) + : null; + const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets; + const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null); + const statusIsError = Boolean(cliError || mcpError); + const caption = tx("settings.apps.caption", "{{cli}} CLI · {{mcp}} MCP") + .replace("{{cli}}", String(cliApps?.installed_count ?? 0)) + .replace("{{mcp}}", String(mcpPresets?.installed_count ?? 0)); return ( -
+
-
-
- {tx("settings.sections.mcp", "MCP")} -

- {tx("settings.mcp.summary", "{{installed}} of {{total}} presets enabled") - .replace("{{installed}}", String(payload?.installed_count ?? 0)) - .replace("{{total}}", String(presets.length))} -

-
- onInstallFilterChange(value as "all" | "installed" | "notInstalled")} - /> +
+

+ {tx( + "settings.apps.description", + "Add local app adapters and connected tool servers that nanobot can use from chat.", + )} +

+ {caption}
- -
+
- + onQueryChange(event.target.value)} - placeholder={tx("settings.mcp.searchPlaceholder", "Search MCP presets")} - className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]" + placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")} + className="h-12 rounded-[14px] border-border/70 bg-card/90 pl-11 text-[15px] shadow-sm" />
- - - - - - {categories.map((item) => ( - onCategoryChange(item)}> - {item === "all" ? tx("settings.mcp.allCategories", "All categories") : item} - - ))} - - + onFilterChange(value as AppsKindFilter)} + />
- + {statusMessage ? ( +
+ {statusMessage} + +
+ ) : null} + + {focusedApp ? ( + + ) : null} {requiresRestartPending ? ( -
+
{tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")} {onRestart ? ( ) : null}
) : null} - {visibleStatusMessage ? ( -
- {visibleStatusMessage} +
+
+ {tx("settings.apps.featured", "Featured")} + + {items.length} +
+ {loading ? ( +
+ + {tx("settings.apps.loading", "Loading Apps...")} +
+ ) : items.length ? ( +
+ {items.map((item) => + item.kind === "cli" ? ( + + ) : ( + + ), + )} +
+ ) : ( +
+ {tx("settings.apps.empty", "No apps match this filter.")} +
+ )} +
+ + {filter !== "cli" ? ( + ) : null} - {showTestDetails ? ( -
-
- {typeof testToolCount === "number" ? ( - - {tx("settings.mcp.toolsFound", "{{count}} tools").replace("{{count}}", String(testToolCount))} - - ) : null} - {payload?.last_action?.checked_at ? ( - {payload.last_action.checked_at} - ) : null} -
- {testToolNames.length ? ( -
- {testToolNames.map((toolName) => ( - - {toolName} - - ))} -
- ) : null} - {payload?.last_action?.error ? ( -

- {payload.last_action.error} -

- ) : null} -
- ) : null} - - {loading ? ( -
- - {tx("settings.mcp.loading", "Loading MCP presets...")} -
- ) : ( -
-
- {filteredPresets.map((preset) => ( - - ))} -
- {!filteredPresets.length ? ( -
- {tx("settings.mcp.empty", "No MCP presets match this filter.")} -
- ) : null} -
- )}
); } +function CliAppsCatalogRow({ + app, + actionKey, + showBrandLogos, + onAction, +}: { + app: CliAppInfo; + actionKey: string | null; + showBrandLogos: boolean; + onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const installBusy = actionKey === `install:${app.name}`; + const updateBusy = actionKey === `update:${app.name}`; + const uninstallBusy = actionKey === `uninstall:${app.name}`; + const testBusy = actionKey === `test:${app.name}`; + const busy = installBusy || updateBusy || uninstallBusy || testBusy; + const description = app.description || app.requires || app.entry_point || app.name; + + return ( +
+ +
+
+

{app.display_name}

+ {tx("settings.apps.cliLabel", "CLI")} +
+

{description}

+
+
+ {app.installed ? ( + <> + + + + + + + + onAction("test", app.name)}> + + {tx("settings.cliApps.test", "Test CLI")} + + onAction("update", app.name)}> + + {tx("settings.cliApps.update", "Update CLI")} + + onAction("uninstall", app.name)}> + + {tx("settings.cliApps.uninstall", "Uninstall CLI")} + + + + onAction("uninstall", app.name)} + > + + + + ) : app.install_supported ? ( + onAction("install", app.name)} + > + + + ) : ( + + + + )} +
+
+ ); +} + +function McpAppsCatalogRow({ + preset, + values, + actionKey, + showBrandLogos, + onFieldChange, + onAction, + onToolsChange, +}: { + preset: McpPresetInfo; + values: Record; + actionKey: string | null; + showBrandLogos: boolean; + onFieldChange: (presetName: string, fieldName: string, value: string) => void; + onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; + onToolsChange: (name: string, enabledTools: string[]) => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const [setupOpen, setSetupOpen] = useState(false); + const [toolsOpen, setToolsOpen] = useState(false); + const enableBusy = actionKey === `enable:${preset.name}`; + const removeBusy = actionKey === `remove:${preset.name}`; + const testBusy = actionKey === `test:${preset.name}`; + const toolsBusy = actionKey === `tools:${preset.name}`; + const busy = enableBusy || removeBusy || testBusy || toolsBusy; + const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); + const hasFields = preset.required_fields.length > 0; + const needsSetupInput = missingFields.length > 0; + const readyInstalled = preset.installed && preset.configured; + const canEnable = + preset.install_supported && + (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim()))); + const toolNames = preset.tool_names ?? []; + const enabledTools = preset.enabled_tools ?? ["*"]; + const allowAllTools = enabledTools.includes("*"); + const enabledSet = new Set(allowAllTools ? toolNames : enabledTools); + const description = preset.description || preset.note || preset.requires || preset.name; + const statusLabel = mcpPresetStatusLabel(preset.status, tx); + + useEffect(() => { + if (preset.configured || !preset.install_supported) setSetupOpen(false); + }, [preset.configured, preset.install_supported]); + + const enableOrOpenSetup = () => { + if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) { + setSetupOpen(true); + return; + } + onAction("enable", preset.name, values); + }; + const submitSetup = () => { + if (!canEnable) return; + onAction("enable", preset.name, values); + }; + const setTools = (next: string[]) => onToolsChange(preset.name, next); + const toggleTool = (toolName: string) => { + const next = new Set(allowAllTools ? toolNames : enabledTools); + if (next.has(toolName)) next.delete(toolName); + else next.add(toolName); + const nextValues = Array.from(next); + setTools(nextValues.length === toolNames.length ? ["*"] : nextValues); + }; + + return ( +
+
+ +
+
+

{preset.display_name}

+ {tx("settings.apps.mcpLabel", "MCP")} +
+

{description}

+
+
+ {readyInstalled ? ( + <> + + + + + + + + onAction("test", preset.name)}> + + {tx("settings.mcp.test", "Test")} + + {toolNames.length ? ( + setToolsOpen((open) => !open)}> + + {tx("settings.mcp.toolScope", "Tools")} + + ) : null} + onAction("remove", preset.name)}> + + {tx("settings.mcp.remove", "Remove")} + + + + onAction("remove", preset.name)} + > + + + + ) : preset.installed && !preset.configured ? ( + { + if (hasFields) setSetupOpen(true); + else onAction("enable", preset.name, values); + }} + > + + + ) : preset.install_supported ? ( + + + + ) : ( + + + + )} +
+
+ + {setupOpen && preset.install_supported && hasFields ? ( +
+
+
+
+ {tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)} +
+

+ {tx("settings.mcp.connectHint", "Add the key from your account settings.")} +

+
+ +
+
+ {preset.required_fields.map((field) => ( + + ))} +
+
+ +
+
+ ) : null} + + {toolsOpen && readyInstalled && toolNames.length ? ( +
+
+
+ {tx("settings.mcp.toolScope", "Tools")} +
+
+ + +
+
+
+ {toolNames.map((toolName) => { + const selected = enabledSet.has(toolName); + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} + +function AppsTypeBadge({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const AppsActionButton = forwardRef void; + children: ReactNode; +}>(function AppsActionButton({ + ariaLabel, + busy, + disabled, + tone = "default", + onClick, + children, +}, ref) { + return ( + + ); +}); + +function appsTitle(item: AppsCatalogItem): string { + return item.kind === "cli" ? item.app.display_name : item.preset.display_name; +} + +function appsReady(item: AppsCatalogItem): boolean { + return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured; +} + +function appsSearchText(item: AppsCatalogItem): string { + if (item.kind === "cli") { + const app = item.app; + return [ + app.display_name, + app.name, + app.category, + app.description, + app.requires, + app.entry_point, + app.source, + ] + .join(" ") + .toLowerCase(); + } + const preset = item.preset; + return [ + preset.display_name, + preset.name, + preset.category, + preset.description, + preset.requires, + preset.note, + preset.transport, + preset.source ?? "", + ] + .join(" ") + .toLowerCase(); +} + function McpCustomServerPanel({ form, configImport, @@ -3017,292 +3319,6 @@ function McpCustomServerPanel({ ); } -function McpPresetCard({ - preset, - values, - actionKey, - showBrandLogos, - onFieldChange, - onAction, - onToolsChange, -}: { - preset: McpPresetInfo; - values: Record; - actionKey: string | null; - showBrandLogos: boolean; - onFieldChange: (presetName: string, fieldName: string, value: string) => void; - onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; - onToolsChange: (name: string, enabledTools: string[]) => void; -}) { - const { t } = useTranslation(); - const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const enableBusy = actionKey === `enable:${preset.name}`; - const removeBusy = actionKey === `remove:${preset.name}`; - const testBusy = actionKey === `test:${preset.name}`; - const toolsBusy = actionKey === `tools:${preset.name}`; - const busy = enableBusy || removeBusy || testBusy || toolsBusy; - const [setupOpen, setSetupOpen] = useState(false); - const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); - const hasFields = preset.required_fields.length > 0; - const needsSetupInput = missingFields.length > 0; - const showSetup = setupOpen && preset.install_supported && hasFields; - const readyInstalled = preset.installed && preset.configured; - const statusLabel = mcpPresetStatusLabel(preset.status, tx); - const canEnable = preset.install_supported && ( - missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())) - ); - const toolNames = preset.tool_names ?? []; - const enabledTools = preset.enabled_tools ?? ["*"]; - const allowAllTools = enabledTools.includes("*"); - const enabledSet = new Set(allowAllTools ? toolNames : enabledTools); - const showToolControls = preset.installed && toolNames.length > 0; - const setTools = (next: string[]) => onToolsChange(preset.name, next); - useEffect(() => { - if (preset.configured || !preset.install_supported) setSetupOpen(false); - }, [preset.configured, preset.install_supported]); - const enableOrOpenSetup = () => { - if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) { - setSetupOpen(true); - return; - } - onAction("enable", preset.name, values); - }; - const submitSetup = () => { - if (!canEnable) return; - onAction("enable", preset.name, values); - }; - const toggleTool = (toolName: string) => { - const next = new Set(allowAllTools ? toolNames : enabledTools); - if (next.has(toolName)) { - next.delete(toolName); - } else { - next.add(toolName); - } - const nextValues = Array.from(next); - setTools(nextValues.length === toolNames.length ? ["*"] : nextValues); - }; - - return ( -
-
- -
-
-

- {preset.display_name} -

- - {preset.category} - - - {statusLabel} - -
-

- {preset.description} -

-
-
- {preset.docs_url ? ( - - - - ) : null} - {readyInstalled ? ( - - - - - - onAction("test", preset.name)}> - - {tx("settings.mcp.test", "Test")} - - onAction("remove", preset.name)}> - - {tx("settings.mcp.remove", "Remove")} - - - - ) : preset.installed && !preset.configured ? ( - - ) : preset.install_supported ? ( - - ) : ( - - )} -
-
- {showSetup ? ( -
-
-
-
- {tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)} -
-

- {tx("settings.mcp.connectHint", "Add the key from your account settings.")} -

-
- -
-
- {preset.required_fields.map((field) => ( - - ))} -
-
- -
-
- ) : null} - {showToolControls ? ( -
-
-
- {toolsBusy ? : } - {tx("settings.mcp.toolScope", "Tools")} -
-
- - -
-
-
- {toolNames.map((toolName) => { - const selected = enabledSet.has(toolName); - return ( - - ); - })} -
-
- ) : preset.installed && !testBusy ? ( -
- {tx("settings.mcp.testForTools", "Run Test to inspect and choose individual tools.")} -
- ) : null} -
- ); -} - function mcpPresetStatusLabel(status: string, tx: (key: string, fallback: string) => string): string { switch (status) { case "configured": @@ -3435,105 +3451,6 @@ function CliAppReadyPanel({ ); } -function CliAppCard({ - app, - actionKey, - showBrandLogos, - onAction, -}: { - app: CliAppInfo; - actionKey: string | null; - showBrandLogos: boolean; - onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; -}) { - const { t } = useTranslation(); - const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const installBusy = actionKey === `install:${app.name}`; - const updateBusy = actionKey === `update:${app.name}`; - const uninstallBusy = actionKey === `uninstall:${app.name}`; - const testBusy = actionKey === `test:${app.name}`; - const busy = installBusy || updateBusy || uninstallBusy || testBusy; - - return ( -
- -
-
-

- {app.display_name} -

- - {app.category} - -
-
- {app.entry_point || app.name} -
-

- {app.requires - ? `${tx("settings.cliApps.requires", "Requires")}: ${app.requires}` - : app.description || tx("settings.cliApps.noDescription", "No description available.")} -

-
-
- {app.installed ? ( - - - - - - onAction("test", app.name)}> - - {tx("settings.cliApps.test", "Test CLI")} - - onAction("update", app.name)}> - - {tx("settings.cliApps.update", "Update CLI")} - - onAction("uninstall", app.name)}> - - {tx("settings.cliApps.uninstall", "Uninstall CLI")} - - - - ) : app.install_supported ? ( - - ) : ( - - )} -
-
- ); -} - function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) { const [logoIndex, setLogoIndex] = useState(0); const bg = app.brand_color || "hsl(var(--muted))"; diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index c5e98f0d0..5ac4d8b38 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -63,7 +63,8 @@ "language": { "label": "Language", "ariaLabel": "Change language" - } + }, + "apps": "Apps" }, "settings": { "backToChat": "Back to chat", @@ -83,7 +84,8 @@ "cliApps": "CLI Apps", "mcp": "MCP", "runtime": "Runtime", - "advanced": "Advanced" + "advanced": "Advanced", + "apps": "Apps" }, "sections": { "interface": "Interface", @@ -96,12 +98,13 @@ "imageDefaults": "Defaults", "webSearch": "Web search", "webBehavior": "Behavior", - "cliApps": "CLI Apps", - "mcp": "MCP", + "cliApps": "CLI apps", + "mcp": "MCP services", "identity": "Identity", "safety": "Safety", "capabilities": "Capabilities", - "integrations": "Integrations" + "integrations": "Integrations", + "apps": "Apps" }, "models": { "selectModel": "Select model", @@ -380,6 +383,20 @@ "selectSize": "Select size", "configureProvider": "Configure provider", "missingCredential": "Configure this provider before enabling image generation." + }, + "apps": { + "description": "Add app CLIs and MCP services nanobot can use from chat.", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "All", + "filterCli": "CLI apps", + "filterMcp": "MCP services", + "enabledSummary": "{{count}} enabled", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "Search Apps", + "featured": "Featured", + "loading": "Loading Apps...", + "empty": "No apps match this filter." } }, "chat": { @@ -596,10 +613,10 @@ } }, "mentions": { - "ariaLabel": "Apps and MCP", - "label": "Plugins", - "cliGroup": "CLI Apps", - "mcpGroup": "MCP servers", + "ariaLabel": "Apps", + "label": "Apps", + "cliGroup": "CLI apps", + "mcpGroup": "MCP services", "cliBadge": "CLI", "mcpBadge": "MCP", "cliDescription": "Use @{{name}} as a local CLI app", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 84a135329..1cbc22c23 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -63,7 +63,8 @@ "language": { "label": "Idioma", "ariaLabel": "Cambiar idioma" - } + }, + "apps": "Apps" }, "settings": { "backToChat": "Volver al chat", @@ -83,7 +84,8 @@ "runtime": "Runtime", "advanced": "Advanced", "cliApps": "Apps CLI", - "mcp": "MCP" + "mcp": "MCP", + "apps": "Apps" }, "sections": { "interface": "Interfaz", @@ -101,7 +103,8 @@ "capabilities": "Capacidades", "integrations": "Integrations", "cliApps": "Apps CLI", - "mcp": "MCP" + "mcp": "Servicios MCP", + "apps": "Apps" }, "rows": { "theme": "Tema", @@ -380,6 +383,20 @@ }, "legal": { "thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo." + }, + "apps": { + "description": "Añade CLI de apps y servicios MCP que nanobot puede usar desde el chat.", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "Todo", + "filterCli": "Apps CLI", + "filterMcp": "Servicios MCP", + "enabledSummary": "{{count}} activados", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "Buscar apps", + "featured": "Destacadas", + "loading": "Cargando apps...", + "empty": "Ninguna app coincide con este filtro." } }, "chat": { @@ -607,10 +624,10 @@ "io": "No se pudo leer este archivo" }, "mentions": { - "ariaLabel": "Apps y MCP", - "label": "Plugins", + "ariaLabel": "Apps", + "label": "Apps", "cliGroup": "Apps CLI", - "mcpGroup": "Servidores MCP", + "mcpGroup": "Servicios MCP", "cliBadge": "CLI", "mcpBadge": "MCP", "cliDescription": "Usar @{{name}} como app CLI local", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 1a443f6d8..49e3c593c 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -63,7 +63,8 @@ "language": { "label": "Langue", "ariaLabel": "Changer de langue" - } + }, + "apps": "Apps" }, "settings": { "backToChat": "Retour à la discussion", @@ -83,7 +84,8 @@ "runtime": "Runtime", "advanced": "Advanced", "cliApps": "Apps CLI", - "mcp": "MCP" + "mcp": "MCP", + "apps": "Apps" }, "sections": { "interface": "Interface", @@ -101,7 +103,8 @@ "capabilities": "Capacités", "integrations": "Integrations", "cliApps": "Apps CLI", - "mcp": "MCP" + "mcp": "Services MCP", + "apps": "Apps" }, "rows": { "theme": "Thème", @@ -380,6 +383,20 @@ }, "legal": { "thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation." + }, + "apps": { + "description": "Ajoutez des CLI d’apps et des services MCP que nanobot peut utiliser dans le chat.", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "Tout", + "filterCli": "Apps CLI", + "filterMcp": "Services MCP", + "enabledSummary": "{{count}} activés", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "Rechercher des apps", + "featured": "En vedette", + "loading": "Chargement des apps...", + "empty": "Aucune app ne correspond à ce filtre." } }, "chat": { @@ -607,10 +624,10 @@ "io": "Impossible de lire ce fichier" }, "mentions": { - "ariaLabel": "Apps et MCP", - "label": "Plugins", + "ariaLabel": "Apps", + "label": "Apps", "cliGroup": "Apps CLI", - "mcpGroup": "Serveurs MCP", + "mcpGroup": "Services MCP", "cliBadge": "CLI", "mcpBadge": "MCP", "cliDescription": "Utiliser @{{name}} comme app CLI locale", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 4b64b7af7..9da7b9596 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -63,7 +63,8 @@ "language": { "label": "Bahasa", "ariaLabel": "Ganti bahasa" - } + }, + "apps": "Aplikasi" }, "settings": { "backToChat": "Kembali ke obrolan", @@ -83,7 +84,8 @@ "runtime": "Runtime", "advanced": "Advanced", "cliApps": "Aplikasi CLI", - "mcp": "MCP" + "mcp": "MCP", + "apps": "Aplikasi" }, "sections": { "interface": "Antarmuka", @@ -100,8 +102,9 @@ "safety": "Safety", "capabilities": "Kapabilitas", "integrations": "Integrations", - "cliApps": "Aplikasi CLI", - "mcp": "MCP" + "cliApps": "App CLI", + "mcp": "Layanan MCP", + "apps": "Aplikasi" }, "rows": { "theme": "Tema", @@ -380,6 +383,20 @@ }, "legal": { "thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan." + }, + "apps": { + "description": "Tambahkan CLI app dan layanan MCP yang dapat digunakan nanobot dari chat.", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "Semua", + "filterCli": "App CLI", + "filterMcp": "Layanan MCP", + "enabledSummary": "{{count}} aktif", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "Cari aplikasi", + "featured": "Unggulan", + "loading": "Memuat aplikasi...", + "empty": "Tidak ada aplikasi yang cocok dengan filter ini." } }, "chat": { @@ -607,10 +624,10 @@ "io": "Tidak dapat membaca file ini" }, "mentions": { - "ariaLabel": "Aplikasi dan MCP", - "label": "Plugin", - "cliGroup": "Aplikasi CLI", - "mcpGroup": "Server MCP", + "ariaLabel": "Aplikasi", + "label": "Aplikasi", + "cliGroup": "App CLI", + "mcpGroup": "Layanan MCP", "cliBadge": "CLI", "mcpBadge": "MCP", "cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 4ddcf1a0b..3a7be7a04 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -63,7 +63,8 @@ "language": { "label": "言語", "ariaLabel": "言語を変更" - } + }, + "apps": "アプリ" }, "settings": { "backToChat": "チャットに戻る", @@ -83,7 +84,8 @@ "runtime": "Runtime", "advanced": "Advanced", "cliApps": "CLI アプリ", - "mcp": "MCP" + "mcp": "MCP", + "apps": "アプリ" }, "sections": { "interface": "インターフェース", @@ -101,7 +103,8 @@ "capabilities": "機能", "integrations": "Integrations", "cliApps": "CLI アプリ", - "mcp": "MCP" + "mcp": "MCP サービス", + "apps": "アプリ" }, "rows": { "theme": "テーマ", @@ -380,6 +383,20 @@ }, "legal": { "thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。" + }, + "apps": { + "description": "チャットから nanobot が使えるアプリ CLI と MCP サービスを追加します。", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "すべて", + "filterCli": "CLI アプリ", + "filterMcp": "MCP サービス", + "enabledSummary": "{{count}} 件有効", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "アプリを検索", + "featured": "おすすめ", + "loading": "アプリを読み込み中...", + "empty": "このフィルターに一致するアプリはありません。" } }, "chat": { @@ -607,10 +624,10 @@ "io": "このファイルを読み込めません" }, "mentions": { - "ariaLabel": "アプリと MCP", - "label": "プラグイン", + "ariaLabel": "アプリ", + "label": "アプリ", "cliGroup": "CLI アプリ", - "mcpGroup": "MCP サーバー", + "mcpGroup": "MCP サービス", "cliBadge": "CLI", "mcpBadge": "MCP", "cliDescription": "@{{name}} をローカル CLI アプリとして使用", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 6e6470cfa..3b0abe24a 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -63,7 +63,8 @@ "language": { "label": "언어", "ariaLabel": "언어 변경" - } + }, + "apps": "앱" }, "settings": { "backToChat": "채팅으로 돌아가기", @@ -83,7 +84,8 @@ "runtime": "Runtime", "advanced": "Advanced", "cliApps": "CLI 앱", - "mcp": "MCP" + "mcp": "MCP", + "apps": "앱" }, "sections": { "interface": "인터페이스", @@ -101,7 +103,8 @@ "capabilities": "기능", "integrations": "Integrations", "cliApps": "CLI 앱", - "mcp": "MCP" + "mcp": "MCP 서비스", + "apps": "앱" }, "rows": { "theme": "테마", @@ -380,6 +383,20 @@ }, "legal": { "thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다." + }, + "apps": { + "description": "채팅에서 nanobot이 사용할 수 있는 앱 CLI와 MCP 서비스를 추가합니다.", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "전체", + "filterCli": "CLI 앱", + "filterMcp": "MCP 서비스", + "enabledSummary": "{{count}}개 활성화됨", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "앱 검색", + "featured": "추천", + "loading": "앱 불러오는 중...", + "empty": "이 필터와 일치하는 앱이 없습니다." } }, "chat": { @@ -607,10 +624,10 @@ "io": "이 파일을 읽을 수 없습니다" }, "mentions": { - "ariaLabel": "앱 및 MCP", - "label": "플러그인", + "ariaLabel": "앱", + "label": "앱", "cliGroup": "CLI 앱", - "mcpGroup": "MCP 서버", + "mcpGroup": "MCP 서비스", "cliBadge": "CLI", "mcpBadge": "MCP", "cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index a98bcbbc7..745dbe9be 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -63,7 +63,8 @@ "language": { "label": "Ngôn ngữ", "ariaLabel": "Đổi ngôn ngữ" - } + }, + "apps": "Ứng dụng" }, "settings": { "backToChat": "Quay lại trò chuyện", @@ -83,7 +84,8 @@ "runtime": "Runtime", "advanced": "Advanced", "cliApps": "Ứng dụng CLI", - "mcp": "MCP" + "mcp": "MCP", + "apps": "Ứng dụng" }, "sections": { "interface": "Giao diện", @@ -101,7 +103,8 @@ "capabilities": "Khả năng", "integrations": "Integrations", "cliApps": "Ứng dụng CLI", - "mcp": "MCP" + "mcp": "Dịch vụ MCP", + "apps": "Ứng dụng" }, "rows": { "theme": "Giao diện", @@ -380,6 +383,20 @@ }, "legal": { "thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận." + }, + "apps": { + "description": "Thêm CLI ứng dụng và dịch vụ MCP để nanobot dùng trong trò chuyện.", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "Tất cả", + "filterCli": "Ứng dụng CLI", + "filterMcp": "Dịch vụ MCP", + "enabledSummary": "{{count}} đã bật", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "Tìm ứng dụng", + "featured": "Nổi bật", + "loading": "Đang tải ứng dụng...", + "empty": "Không có ứng dụng nào khớp với bộ lọc này." } }, "chat": { @@ -607,10 +624,10 @@ "io": "Không thể đọc tệp này" }, "mentions": { - "ariaLabel": "Ứng dụng và MCP", - "label": "Plugin", + "ariaLabel": "Ứng dụng", + "label": "Ứng dụng", "cliGroup": "Ứng dụng CLI", - "mcpGroup": "Máy chủ MCP", + "mcpGroup": "Dịch vụ MCP", "cliBadge": "CLI", "mcpBadge": "MCP", "cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 83b78feb6..58af9321f 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -63,7 +63,8 @@ "language": { "label": "语言", "ariaLabel": "切换语言" - } + }, + "apps": "应用" }, "settings": { "backToChat": "返回对话", @@ -83,7 +84,8 @@ "cliApps": "CLI 应用", "mcp": "MCP", "runtime": "运行时", - "advanced": "高级" + "advanced": "高级", + "apps": "应用" }, "sections": { "interface": "界面", @@ -97,11 +99,12 @@ "webSearch": "网页搜索", "webBehavior": "行为", "cliApps": "CLI 应用", - "mcp": "MCP", + "mcp": "MCP 服务", "identity": "身份", "safety": "安全", "capabilities": "能力", - "integrations": "集成" + "integrations": "集成", + "apps": "应用" }, "models": { "selectModel": "选择模型", @@ -380,6 +383,20 @@ "selectSize": "选择尺寸", "configureProvider": "配置服务商", "missingCredential": "启用图片生成前,请先配置这个服务商。" + }, + "apps": { + "description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "全部", + "filterCli": "CLI 应用", + "filterMcp": "MCP 服务", + "enabledSummary": "已启用 {{count}} 个", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "搜索应用", + "featured": "精选", + "loading": "正在加载应用...", + "empty": "没有符合筛选条件的应用。" } }, "chat": { @@ -595,8 +612,8 @@ } }, "mentions": { - "ariaLabel": "应用和 MCP", - "label": "插件", + "ariaLabel": "应用", + "label": "应用", "cliGroup": "CLI 应用", "mcpGroup": "MCP 服务", "cliBadge": "CLI", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index f59bea6a3..ab70c98ad 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -63,7 +63,8 @@ "language": { "label": "語言", "ariaLabel": "切換語言" - } + }, + "apps": "應用" }, "settings": { "backToChat": "返回對話", @@ -83,7 +84,8 @@ "runtime": "Runtime", "advanced": "Advanced", "cliApps": "CLI 應用", - "mcp": "MCP" + "mcp": "MCP", + "apps": "應用" }, "sections": { "interface": "介面", @@ -101,7 +103,8 @@ "capabilities": "功能", "integrations": "Integrations", "cliApps": "CLI 應用", - "mcp": "MCP" + "mcp": "MCP 服務", + "apps": "應用" }, "rows": { "theme": "主題", @@ -380,6 +383,20 @@ }, "legal": { "thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。" + }, + "apps": { + "description": "新增 nanobot 可在聊天中使用的 App CLI 與 MCP 服務。", + "cliLabel": "CLI", + "mcpLabel": "MCP", + "filterAll": "全部", + "filterCli": "CLI 應用", + "filterMcp": "MCP 服務", + "enabledSummary": "已啟用 {{count}} 個", + "caption": "{{cli}} CLI · {{mcp}} MCP", + "searchPlaceholder": "搜尋應用", + "featured": "精選", + "loading": "正在載入應用...", + "empty": "沒有符合篩選條件的應用。" } }, "chat": { @@ -607,8 +624,8 @@ "io": "無法讀取這個檔案" }, "mentions": { - "ariaLabel": "應用和 MCP", - "label": "插件", + "ariaLabel": "應用", + "label": "應用", "cliGroup": "CLI 應用", "mcpGroup": "MCP 服務", "cliBadge": "CLI", diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 131cd5f95..d8b181e3d 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -280,6 +280,59 @@ export interface SettingsPayload { restart_required_sections?: Array<"runtime" | "web" | "image">; } +export interface AppPackageRef { + manager: string; + name?: string; +} + +export interface AppCapability { + type: "cli" | "mcp" | "skill" | string; + entry_point?: string; + package?: AppPackageRef; + path?: string; + transport?: string; + command?: string; + args?: string[]; + url?: string; + fields?: Array<{ + name: string; + target?: string; + required?: boolean; + secret?: boolean; + env_var?: string | null; + }>; +} + +export interface AppPlan { + supported: boolean; + strategy?: string; + managed_paths?: string[]; + verification?: string[]; +} + +export interface AppTrust { + registry: string; + level: string; + review_status: string; +} + +export interface AppManifest { + schema: "agent-app.v1" | string; + id: string; + display_name: string; + version?: string; + description: string; + category: string; + source: string; + logo_url?: string | null; + brand_color?: string | null; + docs_url?: string | null; + capabilities: AppCapability[]; + install: AppPlan; + remove: AppPlan; + trust: AppTrust; +} + export interface CliAppInfo { name: string; display_name: string; @@ -295,6 +348,7 @@ export interface CliAppInfo { logo_url?: string | null; brand_color?: string | null; skill_installed: boolean; + manifest?: AppManifest; } export interface CliAppsPayload { @@ -304,7 +358,12 @@ export interface CliAppsPayload { last_action?: { ok: boolean; message: string; + installed?: boolean; + removed?: boolean; output?: string | null; + still_available?: boolean; + verification?: string[]; + verification_failed?: string[]; }; } @@ -342,6 +401,7 @@ export interface McpPresetInfo { error?: string | null; enabled_tools?: string[]; source?: "preset" | "custom" | string; + manifest?: AppManifest; } export interface McpPresetsPayload { @@ -364,6 +424,11 @@ export interface McpPresetsPayload { last_action?: { ok: boolean; message: string; + installed?: boolean; + removed?: boolean; + managed_paths_removed?: string[]; + verification?: string[]; + verification_failed?: string[]; tool_count?: number; tool_names?: string[]; checked_at?: string | null; diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 35d3b6401..d6f228838 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -13,6 +13,100 @@ const attachSpy = vi.fn(); const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>(); let mockSessions: ChatSummary[] = []; +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + json: async () => body, + } as Response; +} + +function baseSettingsPayload() { + return { + agent: { + model: "openai/gpt-4o", + provider: "auto", + resolved_provider: "openai", + has_api_key: true, + model_preset: "default", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0.1, + reasoning_effort: null, + timezone: "UTC", + bot_name: "nanobot", + bot_icon: "nb", + tool_hint_max_length: 40, + }, + model_presets: [{ + name: "default", + label: "Default", + active: true, + is_default: true, + model: "openai/gpt-4o", + provider: "auto", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0.1, + reasoning_effort: null, + }], + providers: [], + web_search: { + provider: "duckduckgo", + api_key_hint: null, + base_url: null, + max_results: 5, + timeout: 30, + providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }], + }, + web: { + enable: true, + proxy: null, + user_agent: null, + search: { max_results: 5, timeout: 30 }, + fetch: { use_jina_reader: true }, + }, + image_generation: { + enabled: false, + provider: "openrouter", + provider_configured: false, + model: "openai/gpt-5.4-image-2", + default_aspect_ratio: "1:1", + default_image_size: "1K", + max_images_per_turn: 4, + save_dir: "generated", + providers: [], + }, + runtime: { + config_path: "/tmp/config.json", + workspace_path: "/tmp/workspace", + gateway_host: "127.0.0.1", + gateway_port: 18790, + heartbeat: { + enabled: true, + interval_s: 1800, + keep_recent_messages: 8, + }, + dream: { + schedule: "every 2h", + max_batch_size: 20, + max_iterations: 15, + annotate_line_ages: true, + }, + unified_session: false, + }, + advanced: { + restrict_to_workspace: false, + ssrf_whitelist_count: 0, + mcp_server_count: 0, + exec_enabled: true, + exec_sandbox: null, + exec_path_append_set: false, + }, + requires_restart: false, + }; +} + vi.mock("@/hooks/useSessions", async (importOriginal) => { const React = await import("react"); const actual = await importOriginal(); @@ -709,6 +803,9 @@ describe("App layout", () => { await waitFor(() => expect(connectSpy).toHaveBeenCalled()); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + const searchButton = within(sidebar).getByRole("button", { name: "Search" }); + const appsButton = within(sidebar).getByRole("button", { name: "Apps" }); + expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" })); expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument(); @@ -731,6 +828,7 @@ describe("App layout", () => { expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument(); expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument(); expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument(); + expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument(); expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" })); @@ -822,6 +920,42 @@ describe("App layout", () => { expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); }); + it("opens Apps from the main sidebar without replacing the sidebar", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const href = String(input); + if (href === "/api/settings") { + return jsonResponse(baseSettingsPayload()); + } + if (href === "/api/settings/cli-apps") { + return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" }); + } + if (href === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [], installed_count: 0 }); + } + return { ok: false, status: 404, json: async () => ({}) } as Response; + }), + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + const appsButton = within(sidebar).getByRole("button", { name: "Apps" }); + + fireEvent.click(appsButton); + + expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument(); + expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument(); + expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(document.title).toBe("Apps · nanobot"); + }); + it("returns from settings to the blank start page when no session was active", async () => { mockSessions = [ { diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 9f228c961..c6b4a0c3f 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -28,6 +28,7 @@ const SETTINGS_NAV_KEYS = [ "models", "image", "web", + "apps", "runtime", "advanced", ]; diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx new file mode 100644 index 000000000..220510ed8 --- /dev/null +++ b/webui/src/tests/settings-view.test.tsx @@ -0,0 +1,191 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SettingsView } from "@/components/settings/SettingsView"; +import { ClientProvider } from "@/providers/ClientProvider"; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + json: async () => body, + } as Response; +} + +function settingsPayload() { + return { + agent: { + model: "openai/gpt-4o", + provider: "auto", + resolved_provider: "openai", + has_api_key: true, + model_preset: "default", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0.1, + reasoning_effort: null, + timezone: "UTC", + bot_name: "nanobot", + bot_icon: "nb", + tool_hint_max_length: 40, + }, + model_presets: [{ + name: "default", + label: "Default", + active: true, + is_default: true, + model: "openai/gpt-4o", + provider: "auto", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0.1, + reasoning_effort: null, + }], + providers: [], + web_search: { + provider: "duckduckgo", + api_key_hint: null, + base_url: null, + max_results: 5, + timeout: 30, + providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }], + }, + web: { + enable: true, + proxy: null, + user_agent: null, + search: { max_results: 5, timeout: 30 }, + fetch: { use_jina_reader: true }, + }, + image_generation: { + enabled: false, + provider: "openrouter", + provider_configured: false, + model: "openai/gpt-5.4-image-2", + default_aspect_ratio: "1:1", + default_image_size: "1K", + max_images_per_turn: 4, + save_dir: "generated", + providers: [], + }, + runtime: { + config_path: "/tmp/config.json", + workspace_path: "/tmp/workspace", + gateway_host: "127.0.0.1", + gateway_port: 18790, + heartbeat: { + enabled: true, + interval_s: 1800, + keep_recent_messages: 8, + }, + dream: { + schedule: "every 2h", + max_batch_size: 20, + max_iterations: 15, + annotate_line_ages: true, + }, + unified_session: false, + }, + advanced: { + restrict_to_workspace: false, + ssrf_whitelist_count: 0, + mcp_server_count: 0, + exec_enabled: true, + exec_sandbox: null, + exec_path_append_set: false, + }, + requires_restart: false, + }; +} + +const installedAnyGen = { + name: "anygen", + display_name: "AnyGen", + category: "generation", + description: "Generate docs, slides, websites and more via AnyGen cloud API", + requires: "ANYGEN_API_KEY", + source: "harness", + entry_point: "cli-anything-anygen", + install_supported: true, + installed: true, + available: true, + status: "installed", + logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64", + brand_color: "#111827", + skill_installed: true, +}; + +function renderSettingsView() { + render( + + {}} + onBackToChat={() => {}} + onModelNameChange={() => {}} + /> + , + ); +} + +describe("SettingsView Apps catalog", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => { + 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: [installedAnyGen], + installed_count: 1, + catalog_updated_at: "2026-04-18", + }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [], installed_count: 0 }); + } + if (url === "/api/settings/cli-apps/uninstall?name=anygen") { + return jsonResponse({ + apps: [{ ...installedAnyGen, installed: false, status: "available" }], + installed_count: 0, + catalog_updated_at: "2026-04-18", + last_action: { + ok: true, + message: "Uninstalled CLI for AnyGen.", + still_available: false, + }, + }); + } + return { ok: false, status: 404, json: async () => ({}) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + + renderSettingsView(); + + expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument(); + expect(await screen.findByText("AnyGen")).toBeInTheDocument(); + const uninstall = screen.getByRole("button", { name: "Uninstall CLI" }); + + fireEvent.click(uninstall); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/settings/cli-apps/uninstall?name=anygen", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ), + ); + expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument(); + }); +}); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 6433055eb..19b47c54f 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -346,7 +346,7 @@ describe("ThreadComposer", () => { const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "@", selectionStart: 1 } }); - const palette = screen.getByRole("listbox", { name: "Apps and MCP" }); + const palette = screen.getByRole("listbox", { name: "Apps" }); expect(palette).toBeInTheDocument(); expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute( "aria-selected", @@ -365,7 +365,7 @@ describe("ThreadComposer", () => { expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender"); expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument(); expect(onSend).not.toHaveBeenCalled(); - expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument(); + expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Send message" })); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index d9624983d..1710e68e0 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -1088,7 +1088,7 @@ describe("ThreadShell", () => { )); const input = await screen.findByLabelText("Message input"); - expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument(); + expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument(); const payload: CliAppsPayload = { apps: [{ @@ -1116,7 +1116,7 @@ describe("ThreadShell", () => { }); fireEvent.change(input, { target: { value: "@", selectionStart: 1 } }); - expect(screen.getByRole("listbox", { name: "Apps and MCP" })).toBeInTheDocument(); + expect(screen.getByRole("listbox", { name: "Apps" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument(); }); });