Compare commits

...
Author SHA1 Message Date
Xubin Ren f94e1d73e2 feat(webui): redesign apps discovery 2026-08-12 09:34:13 +09:00
31 changed files with 869 additions and 217 deletions
+33 -4
View File
@@ -104,7 +104,6 @@ _BRANDS: dict[str, tuple[str, str]] = {
"audacity": ("audacity", "#0000CC"), "audacity": ("audacity", "#0000CC"),
"blender": ("blender", "#E87D0D"), "blender": ("blender", "#E87D0D"),
"browser": ("googlechrome", "#4285F4"), "browser": ("googlechrome", "#4285F4"),
"calibre": ("calibre", "#45B29D"),
"chromadb": ("chroma", "#FFDE2D"), "chromadb": ("chroma", "#FFDE2D"),
"comfyui": ("comfyui", "#111827"), "comfyui": ("comfyui", "#111827"),
"contentful": ("contentful", "#2478CC"), "contentful": ("contentful", "#2478CC"),
@@ -158,6 +157,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
_BRAND_DOMAINS: dict[str, tuple[str, str]] = { _BRAND_DOMAINS: dict[str, tuple[str, str]] = {
"3mf": ("3mf.io", "#00A1DE"), "3mf": ("3mf.io", "#00A1DE"),
"anygen": ("anygen.io", "#111827"), "anygen": ("anygen.io", "#111827"),
"calibre": ("calibre-ebook.com", "#45B29D"),
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"), "clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"), "cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
"cloudcompare": ("cloudcompare.org", "#4D83C3"), "cloudcompare": ("cloudcompare.org", "#4D83C3"),
@@ -201,6 +201,13 @@ _BRAND_ALIASES: dict[str, str] = {
} }
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools") _BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
_GENERIC_HOMEPAGE_HOSTS = frozenset({
"bitbucket.org",
"github.com",
"gitlab.com",
"npmjs.com",
"pypi.org",
})
def _now() -> float: def _now() -> float:
@@ -333,11 +340,25 @@ def _brand_candidates(app: dict[str, Any]) -> list[str]:
return candidates return candidates
def _homepage_domain(app: dict[str, Any]) -> str | None:
value = str(app.get("homepage") or "").strip()
try:
parsed = urlparse(value)
except ValueError:
return None
host = (parsed.hostname or "").lower().removeprefix("www.")
if parsed.scheme not in {"http", "https"} or host in _GENERIC_HOMEPAGE_HOSTS:
return None
if not host or "." not in host or any(not label for label in host.split(".")):
return None
return host
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]: def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
declared_logo = str(app.get("logo_url") or "").strip() declared_logo = str(app.get("logo_url") or "").strip()
declared_color = str(app.get("brand_color") or "").strip() or None
if declared_logo.startswith(("https://", "/")): if declared_logo.startswith(("https://", "/")):
declared_color = str(app.get("brand_color") or "").strip() return declared_logo, declared_color
return declared_logo, declared_color or None
brand = None brand = None
domain_brand = None domain_brand = None
@@ -349,13 +370,21 @@ def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
domain_brand = _BRAND_DOMAINS.get(key) domain_brand = _BRAND_DOMAINS.get(key)
if domain_brand: if domain_brand:
break break
brand_color = declared_color or (brand or domain_brand or (None, None))[1]
homepage_domain = _homepage_domain(app)
if homepage_domain:
return (
f"https://www.google.com/s2/favicons?domain={homepage_domain}&sz=64",
brand_color,
)
if not brand: if not brand:
if not domain_brand: if not domain_brand:
return None, None return None, None
domain, color = domain_brand domain, color = domain_brand
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
slug, color = brand slug, color = brand
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", brand_color
def _read_json(path: Path) -> dict[str, Any] | None: def _read_json(path: Path) -> dict[str, Any] | None:
+129
View File
@@ -0,0 +1,129 @@
"""Small, fail-safe registry for the Apps page Featured section."""
from __future__ import annotations
import asyncio
import json
import os
import re
import time
import urllib.request
from pathlib import Path
from typing import Any, cast
REGISTRY_URL = "https://nanobot.wiki/registry/v1/discovery.json"
CACHE_TTL_S = 60 * 60
_MAX_RESPONSE_BYTES = 64 * 1024
_APP_ID_RE = re.compile(r"^(?:cli|mcp):[a-z0-9][a-z0-9._-]*$")
_FALLBACK = {
"schema_version": 1,
"updated_at": "2026-08-12T00:00:00Z",
"featured": [
"mcp:github",
"mcp:playwright",
"mcp:notion",
"mcp:figma",
"mcp:context7",
"cli:obsidian",
"mcp:linear",
"cli:browser",
"cli:1password-cli",
"cli:blender",
"cli:libreoffice",
"cli:zotero",
],
}
_refresh_tasks: dict[Path, asyncio.Task[None]] = {}
def _validated_payload(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
payload = cast(dict[str, object], value)
if payload.get("schema_version") != 1:
return None
updated_at = payload.get("updated_at")
raw_featured = payload.get("featured")
if not isinstance(updated_at, str) or not updated_at.strip():
return None
if not isinstance(raw_featured, list):
return None
featured_values = cast(list[object], raw_featured)
if not 1 <= len(featured_values) <= 12:
return None
featured: list[str] = []
for item in featured_values:
if not isinstance(item, str) or _APP_ID_RE.fullmatch(item) is None:
return None
featured.append(item)
if len(featured) != len(set(featured)):
return None
return {
"schema_version": 1,
"updated_at": updated_at,
"featured": featured,
}
def _read_cache(path: Path) -> dict[str, Any] | None:
try:
return _validated_payload(json.loads(path.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
return None
def _fetch_remote() -> dict[str, Any]:
request = urllib.request.Request(
REGISTRY_URL,
headers={"Accept": "application/json", "User-Agent": "nanobot-apps/1"},
)
with urllib.request.urlopen(request, timeout=3) as response:
raw = response.read(_MAX_RESPONSE_BYTES + 1)
if len(raw) > _MAX_RESPONSE_BYTES:
raise ValueError("Apps discovery response is too large")
payload = _validated_payload(json.loads(raw))
if payload is None:
raise ValueError("Invalid Apps discovery response")
return payload
def _write_cache(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
async def _refresh(path: Path) -> None:
try:
payload = await asyncio.to_thread(_fetch_remote)
await asyncio.to_thread(_write_cache, path, payload)
except Exception:
# Discovery is optional: the bundled list remains usable offline.
pass
def _schedule_refresh(path: Path) -> None:
task = _refresh_tasks.get(path)
if task is not None and not task.done():
return
task = asyncio.create_task(_refresh(path))
_refresh_tasks[path] = task
task.add_done_callback(lambda completed: _refresh_tasks.pop(path, None))
async def discovery_payload(*, data_dir: Path) -> dict[str, Any]:
"""Return cached Featured IDs immediately and refresh stale data in the background."""
cache_path = data_dir / "apps-discovery.json"
cached = _read_cache(cache_path)
try:
fresh = cached is not None and time.time() - cache_path.stat().st_mtime < CACHE_TTL_S
except OSError:
fresh = False
if fresh and cached is not None:
return cached
_schedule_refresh(cache_path)
return {**(cached or _FALLBACK), "refresh_pending": True}
+3
View File
@@ -15,6 +15,7 @@ from nanobot.agent.tools.image_generation import request_image_generation_reload
from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
from nanobot.api.runtime import ApiRuntime, api_runtime_paths from nanobot.api.runtime import ApiRuntime, api_runtime_paths
from nanobot.apps.discovery import discovery_payload
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.registry import load_channel_plugin from nanobot.channels.registry import load_channel_plugin
from nanobot.channels.validation import validate_channel_config from nanobot.channels.validation import validate_channel_config
@@ -127,6 +128,7 @@ _CAPABILITY_ROUTES = {
} }
_SYSTEM_ROUTES = { _SYSTEM_ROUTES = {
"/api/settings/apps-discovery": "apps-discovery",
"/api/settings/cli-apps": "cli-list", "/api/settings/cli-apps": "cli-list",
"/api/settings/cli-apps/install": "cli-install", "/api/settings/cli-apps/install": "cli-install",
"/api/settings/cli-apps/update": "cli-update", "/api/settings/cli-apps/update": "cli-update",
@@ -461,6 +463,7 @@ class WebUISettingsRouter:
def _system_operations(self) -> system_domain.SystemSettingsOperations: def _system_operations(self) -> system_domain.SystemSettingsOperations:
return system_domain.SystemSettingsOperations( return system_domain.SystemSettingsOperations(
apps_discovery_payload=discovery_payload,
cli_apps_payload=cli_apps_payload, cli_apps_payload=cli_apps_payload,
cli_apps_action=cli_apps_action, cli_apps_action=cli_apps_action,
nanobot_features_payload=nanobot_features_payload, nanobot_features_payload=nanobot_features_payload,
+12
View File
@@ -43,6 +43,7 @@ SettingsOperation = Callable[..., Any]
@dataclass(frozen=True) @dataclass(frozen=True)
class SystemSettingsOperations: class SystemSettingsOperations:
apps_discovery_payload: SettingsOperation
cli_apps_payload: SettingsOperation cli_apps_payload: SettingsOperation
cli_apps_action: SettingsOperation cli_apps_action: SettingsOperation
nanobot_features_payload: SettingsOperation nanobot_features_payload: SettingsOperation
@@ -372,6 +373,8 @@ class SystemSettingsHandler:
channel_name: str | None = None, channel_name: str | None = None,
connect_action: str | None = None, connect_action: str | None = None,
) -> SettingsRouteResult: ) -> SettingsRouteResult:
if action == "apps-discovery":
return await self._apps_discovery(operations)
if action == "cli-list": if action == "cli-list":
return await self._cli_apps(request, operations) return await self._cli_apps(request, operations)
if action.startswith("cli-"): if action.startswith("cli-"):
@@ -419,6 +422,15 @@ class SystemSettingsHandler:
return await self._version_check(operations) return await self._version_check(operations)
return SettingsRouteResult.failure(404, "unknown settings action") return SettingsRouteResult.failure(404, "unknown settings action")
async def _apps_discovery(
self,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
payload = await operations.apps_discovery_payload(
data_dir=self.settings.config.path.parent / "catalog",
)
return SettingsRouteResult.success(payload)
async def _cli_apps( async def _cli_apps(
self, self,
request: SettingsRequest, request: SettingsRequest,
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from nanobot.apps import discovery
@pytest.mark.asyncio
async def test_discovery_returns_fallback_then_caches_remote_registry(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
remote = {
"schema_version": 1,
"updated_at": "2026-08-12T01:00:00Z",
"featured": ["mcp:notion", "cli:obsidian"],
}
monkeypatch.setattr(discovery, "_fetch_remote", lambda: remote)
initial = await discovery.discovery_payload(data_dir=tmp_path)
assert initial["featured"][0] == "mcp:github"
assert initial["refresh_pending"] is True
await asyncio.gather(*discovery._refresh_tasks.values())
cached = await discovery.discovery_payload(data_dir=tmp_path)
assert cached == remote
@pytest.mark.asyncio
async def test_discovery_keeps_last_valid_registry_when_refresh_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
cached = {
"schema_version": 1,
"updated_at": "2026-08-11T01:00:00Z",
"featured": ["mcp:github"],
}
discovery._write_cache(tmp_path / "apps-discovery.json", cached)
monkeypatch.setattr(discovery, "CACHE_TTL_S", -1)
monkeypatch.setattr(
discovery,
"_fetch_remote",
lambda: (_ for _ in ()).throw(OSError("offline")),
)
payload = await discovery.discovery_payload(data_dir=tmp_path)
assert payload == {**cached, "refresh_pending": True}
await asyncio.gather(*discovery._refresh_tasks.values())
assert discovery._read_cache(tmp_path / "apps-discovery.json") == cached
@pytest.mark.parametrize(
"featured",
[[], ["unknown:github"], ["mcp:github", "mcp:github"], ["mcp:github"] * 13],
)
def test_discovery_rejects_invalid_featured_lists(featured: list[str]) -> None:
assert discovery._validated_payload({
"schema_version": 1,
"updated_at": "2026-08-12T01:00:00Z",
"featured": featured,
}) is None
+70
View File
@@ -204,6 +204,76 @@ def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64" assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
def test_payload_uses_calibre_official_domain_for_logo(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(
manager._cache_path("harness"),
{
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "calibre",
"display_name": "Calibre",
"entry_point": "cli-anything-calibre",
},
],
},
)
app = next(app for app in manager.payload()["apps"] if app["name"] == "calibre")
assert app["logo_url"] == (
"https://www.google.com/s2/favicons?domain=calibre-ebook.com&sz=64"
)
def test_payload_prefers_colored_official_homepage_logo(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(
manager._cache_path("harness"),
{
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "blender",
"display_name": "Blender",
"homepage": "https://www.blender.org/features/",
"entry_point": "cli-anything-blender",
},
],
},
)
app = next(app for app in manager.payload()["apps"] if app["name"] == "blender")
assert app["logo_url"] == (
"https://www.google.com/s2/favicons?domain=blender.org&sz=64"
)
assert app["brand_color"] == "#E87D0D"
def test_payload_does_not_use_repository_host_as_the_app_logo(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(
manager._cache_path("harness"),
{
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"homepage": "https://github.com/example/gimp-wrapper",
"entry_point": "cli-anything-gimp",
},
],
},
)
app = next(app for app in manager.payload()["apps"] if app["name"] == "gimp")
assert app["logo_url"] == "https://cdn.simpleicons.org/gimp/5C5543"
def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None: def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None:
manager = _manager(tmp_path) manager = _manager(tmp_path)
_write_cache( _write_cache(
+19
View File
@@ -89,6 +89,25 @@ async def test_mcp_list_serializes_local_runtime_failure_snapshot(tmp_path) -> N
assert snapshot_calls == 1 assert snapshot_calls == 1
@pytest.mark.asyncio
async def test_apps_discovery_route_returns_registry(monkeypatch: pytest.MonkeyPatch) -> None:
payload = {
"schema_version": 1,
"updated_at": "2026-08-12T00:00:00Z",
"featured": ["mcp:github"],
}
discover = AsyncMock(return_value=payload)
monkeypatch.setattr("nanobot.webui.settings_routes.discovery_payload", discover)
request = SimpleNamespace(path="/api/settings/apps-discovery", headers=Headers())
response = await _router().dispatch(None, request, request.path)
assert response is not None
assert response.status_code == 200
assert json.loads(response.body) == payload
discover.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None: async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
config = SimpleNamespace( config = SimpleNamespace(
@@ -62,6 +62,7 @@ export function SettingsPage({
apiServiceAction, apiServiceAction,
apiServiceError, apiServiceError,
apiServiceLoading, apiServiceLoading,
appsDiscovery,
appsKindFilter, appsKindFilter,
appsQuery, appsQuery,
automationAction, automationAction,
@@ -387,6 +388,7 @@ export function SettingsPage({
case "apps": case "apps":
return ( return (
<AppsCatalogSettings <AppsCatalogSettings
discovery={appsDiscovery}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
cliAppsLoading={cliAppsLoading} cliAppsLoading={cliAppsLoading}
@@ -410,8 +410,8 @@ export function AppearanceSettings({
<SettingsRow <SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")} title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx( description={tx(
"settings.legal.thirdPartyBrands", "settings.help.brandLogos",
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.", "Load third-party brand logos from external icon services. Turn this off to use local initials.",
)} )}
> >
<ToggleButton <ToggleButton
@@ -49,6 +49,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { isGenericRepositoryLogoUrl, logoFallbackUrls } from "@/lib/provider-brand"; import { isGenericRepositoryLogoUrl, logoFallbackUrls } from "@/lib/provider-brand";
import type { import type {
AppsDiscoveryPayload,
CliAppInfo, CliAppInfo,
CliAppsPayload, CliAppsPayload,
McpOAuthFlowPayload, McpOAuthFlowPayload,
@@ -57,7 +58,7 @@ import type {
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type AppsKindFilter = "ready" | "cli" | "mcp"; export type AppsKindFilter = "discover" | "installed" | "all" | "custom";
type AppsCatalogItem = type AppsCatalogItem =
| { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "cli"; app: CliAppInfo }
| { id: string; kind: "mcp"; preset: McpPresetInfo }; | { id: string; kind: "mcp"; preset: McpPresetInfo };
@@ -65,6 +66,21 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
type CustomMcpAuth = "none" | "oauth" | "headers"; type CustomMcpAuth = "none" | "oauth" | "headers";
export const CLI_APPS_REFRESH_RETRY_MS = 2_000; export const CLI_APPS_REFRESH_RETRY_MS = 2_000;
export const CLI_APPS_REFRESH_MAX_RETRIES = 30; export const CLI_APPS_REFRESH_MAX_RETRIES = 30;
const FEATURED_BATCH_SIZE = 6;
const DEFAULT_FEATURED_IDS = [
"mcp:github",
"mcp:playwright",
"mcp:notion",
"mcp:figma",
"mcp:context7",
"cli:obsidian",
"mcp:linear",
"cli:browser",
"cli:1password-cli",
"cli:blender",
"cli:libreoffice",
"cli:zotero",
];
export interface CustomMcpForm { export interface CustomMcpForm {
name: string; name: string;
@@ -91,6 +107,7 @@ export const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
}; };
export function AppsCatalogSettings({ export function AppsCatalogSettings({
discovery,
cliApps, cliApps,
mcpPresets, mcpPresets,
cliAppsLoading, cliAppsLoading,
@@ -134,6 +151,7 @@ export function AppsCatalogSettings({
onRestart, onRestart,
isRestarting, isRestarting,
}: { }: {
discovery: AppsDiscoveryPayload | null;
cliApps: CliAppsPayload | null; cliApps: CliAppsPayload | null;
mcpPresets: McpPresetsPayload | null; mcpPresets: McpPresetsPayload | null;
cliAppsLoading: boolean; cliAppsLoading: boolean;
@@ -178,33 +196,41 @@ export function AppsCatalogSettings({
isRestarting?: boolean; isRestarting?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [featuredBatch, setFeaturedBatch] = useState(0);
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const filterOptions = [ const filterOptions = [
{ value: "ready", label: tx("settings.apps.filterAll", "Ready") }, { value: "discover", label: tx("settings.apps.discover", "Discover") },
{ value: "cli", label: tx("settings.apps.filterCli", "Apps") }, { value: "installed", label: tx("settings.apps.installed", "Installed") },
{ value: "mcp", label: tx("settings.apps.filterMcp", "MCP") }, { value: "all", label: tx("settings.apps.allApps", "All apps") },
]; ];
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const items: AppsCatalogItem[] = [ const allItems: AppsCatalogItem[] = [
...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })), ...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })),
...(mcpPresets?.presets ?? []).map((preset) => ({ ...(mcpPresets?.presets ?? []).map((preset) => ({
id: `mcp:${preset.name}`, id: `mcp:${preset.name}`,
kind: "mcp" as const, kind: "mcp" as const,
preset, preset,
})), })),
] ].sort((left, right) => appsTitle(left).localeCompare(appsTitle(right)));
.filter((item) => { const installedItems = allItems.filter(appsInstalled);
if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery); const featuredIds = discovery?.featured ?? DEFAULT_FEATURED_IDS;
if (filter === "ready") return appsReady(item); const selectedFeatured = selectFeaturedApps(allItems, featuredIds);
if (filter === "cli") { const featuredCandidates = selectedFeatured.length
return item.kind === "cli" || item.preset.source === "agent-plugin"; ? selectedFeatured
} : selectFeaturedApps(allItems, DEFAULT_FEATURED_IDS);
return item.kind === "mcp" && item.preset.source !== "agent-plugin"; const featuredBatchCount = Math.max(1, Math.ceil(featuredCandidates.length / FEATURED_BATCH_SIZE));
}) const featuredBatchIndex = featuredBatch % featuredBatchCount;
.sort((left, right) => { const featuredItems = featuredCandidates.slice(
const rank = Number(!appsReady(left)) - Number(!appsReady(right)); featuredBatchIndex * FEATURED_BATCH_SIZE,
return rank || appsTitle(left).localeCompare(appsTitle(right)); (featuredBatchIndex + 1) * FEATURED_BATCH_SIZE,
}); );
const visibleItems = normalizedQuery
? allItems.filter((item) => appsSearchText(item).includes(normalizedQuery))
: filter === "installed"
? installedItems
: filter === "all"
? allItems
: [];
const focusedApp = cliFocusName const focusedApp = cliFocusName
? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed) ? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
: null; : null;
@@ -212,23 +238,11 @@ export function AppsCatalogSettings({
(cliAppsLoading || mcpPresetsLoading) && (cliAppsLoading || mcpPresetsLoading) &&
!cliApps && !cliApps &&
!mcpPresets; !mcpPresets;
const cliAppCount = cliApps?.apps.length ?? 0;
const emptyTitle = normalizedQuery const emptyTitle = normalizedQuery
? tx("settings.apps.empty", "No tools match your search.") ? tx("settings.apps.empty", "No tools match your search.")
: filter === "cli" : filter === "installed"
? tx("settings.apps.emptyApps", "No apps available.") ? tx("settings.apps.emptyInstalled", "No apps installed yet.")
: filter === "mcp" : tx("settings.apps.emptyApps", "No apps available.");
? tx("settings.apps.emptyIntegrations", "No MCP tools available.")
: tx("settings.apps.emptyReady", "No tools are ready yet.");
const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery
? null
: filter === "cli"
? "mcp"
: filter === "mcp"
? (cliAppCount ? "cli" : null)
: cliAppCount
? "cli"
: "mcp";
const statusMessage = const statusMessage =
cliError || cliError ||
mcpError || mcpError ||
@@ -242,11 +256,58 @@ export function AppsCatalogSettings({
mcpOAuthFlow.completion_input, mcpOAuthFlow.completion_input,
) )
: ""; : "";
const selectFilter = (value: AppsKindFilter) => {
if (query) onQueryChange("");
onFilterChange(value);
};
const renderItem = (item: AppsCatalogItem) => item.kind === "cli" ? (
<CliAppsCatalogRow
key={item.id}
app={item.app}
actionKey={cliActionKey}
showBrandLogos={showBrandLogos}
onAction={onCliAction}
/>
) : (
<McpAppsCatalogRow
key={item.id}
preset={item.preset}
values={mcpFieldValues[item.preset.name] ?? {}}
actionKey={mcpActionKey}
oauthFlow={mcpOAuthFlow?.name === item.preset.name ? mcpOAuthFlow : null}
oauthPopupBlocked={mcpOAuthPopupBlocked}
oauthCallbackUrl={mcpOAuthCallbackUrl}
oauthCompleting={mcpOAuthCompleting}
oauthCallbackError={mcpOAuthCallbackError}
showBrandLogos={showBrandLogos}
showTypeBadge
onFieldChange={onMcpFieldChange}
onAction={onMcpAction}
onOAuthConnect={onMcpOAuthConnect}
onOAuthCancel={onMcpOAuthCancel}
onOAuthOpen={onMcpOAuthOpen}
onOAuthCallbackUrlChange={onMcpOAuthCallbackUrlChange}
onOAuthComplete={onMcpOAuthComplete}
onToolsChange={onMcpToolsChange}
/>
);
const catalogGrid = (items: AppsCatalogItem[]) => (
<div className="grid grid-cols-1 border-t border-border/45 xl:grid-cols-2">
{items.map((item) => (
<div key={item.id} className="min-w-0 border-b border-border/45 xl:odd:border-r">
{renderItem(item)}
</div>
))}
</div>
);
return ( return (
<div className="space-y-7"> <div className="space-y-7">
<div role="status" className="sr-only">{oauthStatusAnnouncement}</div> <div role="status" className="sr-only">{oauthStatusAnnouncement}</div>
<section className="space-y-4"> <section className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> <p className="text-[14px] leading-6 text-muted-foreground">
{tx("settings.apps.description", "Add tools to nanobot, then @ them in chat.")}
</p>
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden /> <Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input <Input
@@ -262,8 +323,17 @@ export function AppsCatalogSettings({
<SegmentedControl <SegmentedControl
value={filter} value={filter}
options={filterOptions} options={filterOptions}
onChange={(value) => onFilterChange(value as AppsKindFilter)} onChange={(value) => selectFilter(value as AppsKindFilter)}
/> />
<Button
type="button"
variant={filter === "custom" ? "secondary" : "outline"}
className="h-10 shrink-0 rounded-full px-4"
onClick={() => selectFilter("custom")}
>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
{tx("settings.apps.addCustom", "Add custom")}
</Button>
</div> </div>
</section> </section>
@@ -287,62 +357,22 @@ export function AppsCatalogSettings({
/> />
) : null} ) : null}
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4"> {loading ? (
<div className="flex items-center justify-between border-b border-border/45 pb-3"> <section className="rounded-[22px] bg-settings-surface">
<SettingsSectionTitle>
{filter === "mcp"
? tx("settings.apps.mcpTools", "MCP tools")
: tx("settings.apps.featured", "Tools")}
</SettingsSectionTitle>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{items.length}
</span>
</div>
{loading ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground"> <div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.apps.loading", "Loading Apps...")} {tx("settings.apps.loading", "Loading Apps...")}
</div> </div>
) : items.length ? ( </section>
<div className="grid grid-cols-1 gap-x-10 gap-y-1 py-3 xl:grid-cols-2"> ) : normalizedQuery ? (
{items.map((item) => <section>
item.kind === "cli" ? ( <div className="mb-3 flex items-baseline gap-2 px-1">
<CliAppsCatalogRow <SettingsSectionTitle>{tx("settings.apps.searchResults", "Search results")}</SettingsSectionTitle>
key={item.id} <span className="text-[12px] text-muted-foreground">{visibleItems.length}</span>
app={item.app}
actionKey={cliActionKey}
showBrandLogos={showBrandLogos}
onAction={onCliAction}
/>
) : (
<McpAppsCatalogRow
key={item.id}
preset={item.preset}
values={mcpFieldValues[item.preset.name] ?? {}}
actionKey={mcpActionKey}
oauthFlow={mcpOAuthFlow?.name === item.preset.name ? mcpOAuthFlow : null}
oauthPopupBlocked={mcpOAuthPopupBlocked}
oauthCallbackUrl={mcpOAuthCallbackUrl}
oauthCompleting={mcpOAuthCompleting}
oauthCallbackError={mcpOAuthCallbackError}
showBrandLogos={showBrandLogos}
showTypeBadge={filter !== "mcp"}
onFieldChange={onMcpFieldChange}
onAction={onMcpAction}
onOAuthConnect={onMcpOAuthConnect}
onOAuthCancel={onMcpOAuthCancel}
onOAuthOpen={onMcpOAuthOpen}
onOAuthCallbackUrlChange={onMcpOAuthCallbackUrlChange}
onOAuthComplete={onMcpOAuthComplete}
onToolsChange={onMcpToolsChange}
/>
),
)}
</div> </div>
) : ( {visibleItems.length ? catalogGrid(visibleItems) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground"> <div className="py-12 text-center text-sm text-muted-foreground">
<p>{emptyTitle}</p> <p>{emptyTitle}</p>
{normalizedQuery ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -351,30 +381,46 @@ export function AppsCatalogSettings({
> >
{tx("settings.apps.clearSearch", "Clear search")} {tx("settings.apps.clearSearch", "Clear search")}
</Button> </Button>
) : emptyBrowseTarget ? ( </div>
)}
</section>
) : filter === "discover" ? (
<section>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div className="flex items-baseline gap-2">
<SettingsSectionTitle>{tx("settings.apps.featured", "Featured")}</SettingsSectionTitle>
<span className="text-[12px] text-muted-foreground">{featuredItems.length}</span>
</div>
<div className="flex items-center gap-1">
{featuredBatchCount > 1 ? (
<Button
type="button"
variant="ghost"
className="h-8 rounded-full px-3 text-[12px] text-muted-foreground"
onClick={() => setFeaturedBatch((batch) => (batch + 1) % featuredBatchCount)}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.apps.nextFeatured", "Show another")}
</Button>
) : null}
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
className="mt-4 rounded-full" className="h-8 rounded-full px-3 text-[12px] text-muted-foreground"
onClick={() => onFilterChange(emptyBrowseTarget)} onClick={() => selectFilter("all")}
> >
{emptyBrowseTarget === "cli" {tx("settings.apps.browseAll", "Browse all")}
? tx("settings.apps.browseApps", "Browse apps") <ChevronRight className="ml-1 h-3.5 w-3.5" aria-hidden />
: tx("settings.apps.browseIntegrations", "Browse MCP tools")}
</Button> </Button>
) : ( </div>
<p className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.apps.emptyIntegrationsHint",
"Add a custom MCP server below.",
)}
</p>
)}
</div> </div>
)} {featuredItems.length ? catalogGrid(featuredItems) : (
</section> <div className="py-12 text-center text-sm text-muted-foreground">
{tx("settings.apps.emptyApps", "No apps available.")}
{filter === "mcp" ? ( </div>
)}
</section>
) : filter === "custom" ? (
<McpCustomServerPanel <McpCustomServerPanel
form={customMcpForm} form={customMcpForm}
configImport={mcpConfigImport} configImport={mcpConfigImport}
@@ -384,7 +430,33 @@ export function AppsCatalogSettings({
onSave={onSaveCustomMcp} onSave={onSaveCustomMcp}
onImportConfig={onImportMcpConfig} onImportConfig={onImportMcpConfig}
/> />
) : null} ) : (
<section>
<div className="mb-3 flex items-baseline gap-2 px-1">
<SettingsSectionTitle>
{filter === "installed"
? tx("settings.apps.installed", "Installed")
: tx("settings.apps.allApps", "All apps")}
</SettingsSectionTitle>
<span className="text-[12px] text-muted-foreground">{visibleItems.length}</span>
</div>
{visibleItems.length ? catalogGrid(visibleItems) : (
<div className="py-12 text-center text-sm text-muted-foreground">
<p>{emptyTitle}</p>
{filter === "installed" ? (
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => selectFilter("discover")}
>
{tx("settings.apps.discover", "Discover")}
</Button>
) : null}
</div>
)}
</section>
)}
</div> </div>
); );
} }
@@ -1110,12 +1182,20 @@ function appsTitle(item: AppsCatalogItem): string {
return item.kind === "cli" ? item.app.display_name : item.preset.display_name; return item.kind === "cli" ? item.app.display_name : item.preset.display_name;
} }
function appsReady(item: AppsCatalogItem): boolean { function appsInstalled(item: AppsCatalogItem): boolean {
if (item.kind === "cli") return item.app.installed; if (item.kind === "cli") return item.app.installed;
if (item.preset.enabled !== undefined) return item.preset.enabled; return item.preset.installed;
return item.preset.installed && }
item.preset.configured &&
item.preset.runtime_status === "connected"; function selectFeaturedApps(
items: AppsCatalogItem[],
preferredIds: string[],
): AppsCatalogItem[] {
const byId = new Map(items.map((item) => [item.id, item]));
return preferredIds.flatMap((id) => {
const item = byId.get(id);
return item ? [item] : [];
});
} }
function appsSearchText(item: AppsCatalogItem): string { function appsSearchText(item: AppsCatalogItem): string {
@@ -8,6 +8,7 @@ import {
import type { SystemSettingsState } from "@/components/settings/system/useSystemSettingsState"; import type { SystemSettingsState } from "@/components/settings/system/useSystemSettingsState";
import { import {
fetchApiService, fetchApiService,
fetchAppsDiscovery,
fetchAutomations, fetchAutomations,
fetchCliApps, fetchCliApps,
fetchMcpPresets, fetchMcpPresets,
@@ -33,6 +34,7 @@ export function useSystemSettingsEffects({
setApiService, setApiService,
setApiServiceError, setApiServiceError,
setApiServiceLoading, setApiServiceLoading,
setAppsDiscovery,
setAutomations, setAutomations,
setAutomationsError, setAutomationsError,
setAutomationsLoading, setAutomationsLoading,
@@ -47,6 +49,30 @@ export function useSystemSettingsEffects({
setNanobotFeaturesLoading, setNanobotFeaturesLoading,
} = state; } = state;
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
let retry: number | null = null;
let retryCount = 0;
const load = () => {
fetchAppsDiscovery(getToken())
.then((payload) => {
if (cancelled) return;
setAppsDiscovery(payload);
if (payload.refresh_pending && retryCount < 3) {
retryCount += 1;
retry = window.setTimeout(load, CLI_APPS_REFRESH_RETRY_MS);
}
})
.catch(() => undefined);
};
load();
return () => {
cancelled = true;
if (retry !== null) window.clearTimeout(retry);
};
}, [activeSection, getToken, setAppsDiscovery]);
useEffect(() => { useEffect(() => {
if (activeSection !== "apps") return; if (activeSection !== "apps") return;
let cancelled = false; let cancelled = false;
@@ -11,6 +11,7 @@ import {
} from "@/components/settings/system/AppsSettings"; } from "@/components/settings/system/AppsSettings";
import type { import type {
ApiServicePayload, ApiServicePayload,
AppsDiscoveryPayload,
AutomationsPayload, AutomationsPayload,
CliAppsPayload, CliAppsPayload,
McpOAuthFlowPayload, McpOAuthFlowPayload,
@@ -21,6 +22,7 @@ import type {
} from "@/lib/types"; } from "@/lib/types";
export function useSystemSettingsState() { export function useSystemSettingsState() {
const [appsDiscovery, setAppsDiscovery] = useState<AppsDiscoveryPayload | null>(null);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null); const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null); const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null); const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
@@ -54,7 +56,7 @@ export function useSystemSettingsState() {
const [cliAppsError, setCliAppsError] = useState<string | null>(null); const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [nanobotFeaturesError, setNanobotFeaturesError] = useState<string | null>(null); const [nanobotFeaturesError, setNanobotFeaturesError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null); const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("cli"); const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("discover");
const [mcpMessage, setMcpMessage] = useState<string | null>(null); const [mcpMessage, setMcpMessage] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null); const [mcpError, setMcpError] = useState<string | null>(null);
const [automationsError, setAutomationsError] = useState<string | null>(null); const [automationsError, setAutomationsError] = useState<string | null>(null);
@@ -72,6 +74,7 @@ export function useSystemSettingsState() {
apiServiceAction, apiServiceAction,
apiServiceError, apiServiceError,
apiServiceLoading, apiServiceLoading,
appsDiscovery,
appsKindFilter, appsKindFilter,
appsQuery, appsQuery,
automationAction, automationAction,
@@ -115,6 +118,7 @@ export function useSystemSettingsState() {
setApiServiceAction, setApiServiceAction,
setApiServiceError, setApiServiceError,
setApiServiceLoading, setApiServiceLoading,
setAppsDiscovery,
setAppsKindFilter, setAppsKindFilter,
setAppsQuery, setAppsQuery,
setAutomationAction, setAutomationAction,
@@ -110,7 +110,7 @@ export function useSettingsController({
} = capabilityState; } = capabilityState;
const systemState = useSystemSettingsState(); const systemState = useSystemSettingsState();
const { const {
apiService, apiServiceAction, apiServiceError, apiServiceLoading, appsKindFilter, appsQuery, apiService, apiServiceAction, apiServiceError, apiServiceLoading, appsDiscovery, appsKindFilter, appsQuery,
automationAction, automationPendingDelete, automationPendingEdit, automations, automationAction, automationPendingDelete, automationPendingEdit, automations,
automationsError, automationsFilter, automationsLoading, automationsQuery, automationsSort, automationsError, automationsFilter, automationsLoading, automationsQuery, automationsSort,
channelsQuery, cliApps, cliAppsAction, cliAppsError, cliAppsFocusName, cliAppsLoading, channelsQuery, cliApps, cliAppsAction, cliAppsError, cliAppsFocusName, cliAppsLoading,
@@ -459,6 +459,7 @@ export function useSettingsController({
apiServiceAction, apiServiceAction,
apiServiceError, apiServiceError,
apiServiceLoading, apiServiceLoading,
appsDiscovery,
appsKindFilter, appsKindFilter,
appsQuery, appsQuery,
automationAction, automationAction,
+10 -5
View File
@@ -241,7 +241,7 @@
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Choose how much agent activity chrome to show by default.",
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.", "fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.", "brandLogos": "Load third-party brand logos from external icon services. Turn this off to use local initials.",
"maxResults": "Results returned by each web_search call.", "maxResults": "Results returned by each web_search call.",
"timeout": "Seconds before a search provider request times out.", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "Use Jina Reader for web_fetch when available.",
@@ -557,9 +557,6 @@
"capabilityOpenAISearch": "OpenAI web search", "capabilityOpenAISearch": "OpenAI web search",
"capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat." "capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat."
}, },
"legal": {
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
},
"image": { "image": {
"selectProvider": "Select provider", "selectProvider": "Select provider",
"selectAspect": "Select aspect", "selectAspect": "Select aspect",
@@ -606,6 +603,14 @@
}, },
"apps": { "apps": {
"description": "Add tools to nanobot, then @ them in chat.", "description": "Add tools to nanobot, then @ them in chat.",
"discover": "Discover",
"installed": "Installed",
"allApps": "All apps",
"addCustom": "Add custom",
"emptyInstalled": "No apps installed yet.",
"searchResults": "Search results",
"browseAll": "Browse all",
"nextFeatured": "Show another",
"cliLabel": "App", "cliLabel": "App",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Channel", "channelLabel": "Channel",
@@ -617,7 +622,7 @@
"enabledSummary": "{{count}} ready", "enabledSummary": "{{count}} ready",
"caption": "{{cli}} apps · {{mcp}} MCP tools", "caption": "{{cli}} apps · {{mcp}} MCP tools",
"searchPlaceholder": "Search tools", "searchPlaceholder": "Search tools",
"featured": "Tools", "featured": "Featured",
"mcpTools": "MCP tools", "mcpTools": "MCP tools",
"loading": "Loading Apps...", "loading": "Loading Apps...",
"empty": "No tools match your search.", "empty": "No tools match your search.",
+10 -5
View File
@@ -203,7 +203,7 @@
"currentModel": "Se usa para nuevas respuestas.", "currentModel": "Se usa para nuevas respuestas.",
"selectedModelProvider": "Definido por el modelo seleccionado.", "selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Definido por el modelo seleccionado.", "selectedModelValue": "Definido por el modelo seleccionado.",
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.", "brandLogos": "Carga logotipos de marcas de terceros desde servicios de iconos externos. Desactívalo para usar iniciales locales.",
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.", "cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.", "cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor nativo.", "logs": "Abre la carpeta de registros del motor nativo.",
@@ -588,11 +588,16 @@
"title": "Observabilidad", "configured": "Las credenciales de trazas están disponibles para nanobot.", "title": "Observabilidad", "configured": "Las credenciales de trazas están disponibles para nanobot.",
"environment": "Configura LANGFUSE_SECRET_KEY y LANGFUSE_PUBLIC_KEY y reinicia nanobot.", "enable": "Habilitar soporte de trazas" "environment": "Configura LANGFUSE_SECRET_KEY y LANGFUSE_PUBLIC_KEY y reinicia nanobot.", "enable": "Habilitar soporte de trazas"
}, },
"legal": {
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
},
"apps": { "apps": {
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.", "description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
"discover": "Descubrir",
"installed": "Instaladas",
"allApps": "Todas las apps",
"addCustom": "Añadir personalizada",
"emptyInstalled": "Aún no hay apps instaladas.",
"searchResults": "Resultados de búsqueda",
"browseAll": "Ver todas",
"nextFeatured": "Mostrar otras",
"cliLabel": "Aplicación", "cliLabel": "Aplicación",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Canal", "channelLabel": "Canal",
@@ -604,7 +609,7 @@
"enabledSummary": "{{count}} listos", "enabledSummary": "{{count}} listos",
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP", "caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
"searchPlaceholder": "Buscar aplicaciones", "searchPlaceholder": "Buscar aplicaciones",
"featured": "Herramientas", "featured": "Destacadas",
"mcpTools": "Herramientas MCP", "mcpTools": "Herramientas MCP",
"loading": "Cargando aplicaciones...", "loading": "Cargando aplicaciones...",
"empty": "Ninguna herramienta coincide con tu búsqueda.", "empty": "Ninguna herramienta coincide con tu búsqueda.",
+10 -5
View File
@@ -203,7 +203,7 @@
"currentModel": "Utilisée pour les nouvelles réponses.", "currentModel": "Utilisée pour les nouvelles réponses.",
"selectedModelProvider": "Défini par le modèle sélectionné.", "selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "Défini par le modèle sélectionné.", "selectedModelValue": "Défini par le modèle sélectionné.",
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.", "brandLogos": "Charge les logos de marques tierces depuis des services dicônes externes. Désactivez cette option pour utiliser des initiales locales.",
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI dapplications que nanobot peut exécuter localement ; les applications natives restent inchangées.", "cliAppsCatalog": "Installe uniquement les adaptateurs CLI dapplications que nanobot peut exécuter localement ; les applications natives restent inchangées.",
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.", "cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
"logs": "Ouvre le dossier des journaux du moteur natif.", "logs": "Ouvre le dossier des journaux du moteur natif.",
@@ -587,11 +587,16 @@
"title": "Observabilité", "configured": "Les identifiants de traçage sont disponibles pour nanobot.", "title": "Observabilité", "configured": "Les identifiants de traçage sont disponibles pour nanobot.",
"environment": "Définissez LANGFUSE_SECRET_KEY et LANGFUSE_PUBLIC_KEY, puis redémarrez nanobot.", "enable": "Activer le traçage" "environment": "Définissez LANGFUSE_SECRET_KEY et LANGFUSE_PUBLIC_KEY, puis redémarrez nanobot.", "enable": "Activer le traçage"
}, },
"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": { "apps": {
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.", "description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
"discover": "Découvrir",
"installed": "Installées",
"allApps": "Toutes les apps",
"addCustom": "Ajouter une app",
"emptyInstalled": "Aucune app installée pour le moment.",
"searchResults": "Résultats de recherche",
"browseAll": "Tout afficher",
"nextFeatured": "Voir dautres",
"cliLabel": "Application", "cliLabel": "Application",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Canal", "channelLabel": "Canal",
@@ -603,7 +608,7 @@
"enabledSummary": "{{count}} prêts", "enabledSummary": "{{count}} prêts",
"caption": "{{cli}} applications · {{mcp}} outils MCP", "caption": "{{cli}} applications · {{mcp}} outils MCP",
"searchPlaceholder": "Rechercher des applications", "searchPlaceholder": "Rechercher des applications",
"featured": "Outils", "featured": "À la une",
"mcpTools": "Outils MCP", "mcpTools": "Outils MCP",
"loading": "Chargement des applications...", "loading": "Chargement des applications...",
"empty": "Aucun outil ne correspond à votre recherche.", "empty": "Aucun outil ne correspond à votre recherche.",
+10 -5
View File
@@ -203,7 +203,7 @@
"currentModel": "Digunakan untuk balasan baru.", "currentModel": "Digunakan untuk balasan baru.",
"selectedModelProvider": "Ditentukan oleh model yang dipilih.", "selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.", "selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.", "brandLogos": "Muat logo merek pihak ketiga dari layanan ikon eksternal. Nonaktifkan untuk menggunakan inisial lokal.",
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.", "cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.", "cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
"logs": "Buka folder log mesin asli.", "logs": "Buka folder log mesin asli.",
@@ -587,11 +587,16 @@
"title": "Observabilitas", "configured": "Kredensial tracing tersedia untuk nanobot.", "title": "Observabilitas", "configured": "Kredensial tracing tersedia untuk nanobot.",
"environment": "Atur LANGFUSE_SECRET_KEY dan LANGFUSE_PUBLIC_KEY, lalu mulai ulang nanobot.", "enable": "Aktifkan dukungan tracing" "environment": "Atur LANGFUSE_SECRET_KEY dan LANGFUSE_PUBLIC_KEY, lalu mulai ulang nanobot.", "enable": "Aktifkan dukungan tracing"
}, },
"legal": {
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
},
"apps": { "apps": {
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.", "description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
"discover": "Temukan",
"installed": "Terpasang",
"allApps": "Semua aplikasi",
"addCustom": "Tambah kustom",
"emptyInstalled": "Belum ada aplikasi yang terpasang.",
"searchResults": "Hasil pencarian",
"browseAll": "Lihat semua",
"nextFeatured": "Tampilkan lainnya",
"cliLabel": "Aplikasi", "cliLabel": "Aplikasi",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Kanal", "channelLabel": "Kanal",
@@ -603,7 +608,7 @@
"enabledSummary": "{{count}} siap", "enabledSummary": "{{count}} siap",
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP", "caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
"searchPlaceholder": "Cari aplikasi", "searchPlaceholder": "Cari aplikasi",
"featured": "Alat", "featured": "Unggulan",
"mcpTools": "Alat MCP", "mcpTools": "Alat MCP",
"loading": "Memuat aplikasi...", "loading": "Memuat aplikasi...",
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.", "empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
+10 -5
View File
@@ -203,7 +203,7 @@
"currentModel": "新しい返信に使用します。", "currentModel": "新しい返信に使用します。",
"selectedModelProvider": "選択したモデルによって設定されます。", "selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。", "selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。", "brandLogos": "外部のアイコンサービスからサードパーティのブランドロゴを読み込みます。オフにするとローカルのイニシャルアイコンを使用します。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。", "cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。", "cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
"logs": "ネイティブエンジンのログフォルダーを開きます。", "logs": "ネイティブエンジンのログフォルダーを開きます。",
@@ -587,11 +587,16 @@
"title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。", "title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。",
"environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化" "environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化"
}, },
"legal": {
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
},
"apps": { "apps": {
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。", "description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
"discover": "見つける",
"installed": "インストール済み",
"allApps": "すべてのアプリ",
"addCustom": "カスタム追加",
"emptyInstalled": "インストール済みのアプリはありません。",
"searchResults": "検索結果",
"browseAll": "すべて見る",
"nextFeatured": "ほかを見る",
"cliLabel": "アプリ", "cliLabel": "アプリ",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "チャンネル", "channelLabel": "チャンネル",
@@ -603,7 +608,7 @@
"enabledSummary": "{{count}} 件使用可能", "enabledSummary": "{{count}} 件使用可能",
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件", "caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
"searchPlaceholder": "アプリを検索", "searchPlaceholder": "アプリを検索",
"featured": "ツール", "featured": "おすすめ",
"mcpTools": "MCP ツール", "mcpTools": "MCP ツール",
"loading": "アプリを読み込み中...", "loading": "アプリを読み込み中...",
"empty": "検索条件に一致するツールはありません。", "empty": "検索条件に一致するツールはありません。",
+10 -5
View File
@@ -203,7 +203,7 @@
"currentModel": "새 응답에 사용됩니다.", "currentModel": "새 응답에 사용됩니다.",
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.", "selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
"selectedModelValue": "선택한 모델에 의해 설정됩니다.", "selectedModelValue": "선택한 모델에 의해 설정됩니다.",
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.", "brandLogos": "외부 아이콘 서비스에서 타사 브랜드 로고를 불러옵니다. 끄면 로컬 이니셜 아이콘을 사용합니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.", "cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.", "cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
"logs": "네이티브 엔진 로그 폴더를 엽니다.", "logs": "네이티브 엔진 로그 폴더를 엽니다.",
@@ -587,11 +587,16 @@
"title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.", "title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.",
"environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화" "environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화"
}, },
"legal": {
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
},
"apps": { "apps": {
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.", "description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
"discover": "둘러보기",
"installed": "설치됨",
"allApps": "모든 앱",
"addCustom": "사용자 지정 추가",
"emptyInstalled": "설치된 앱이 없습니다.",
"searchResults": "검색 결과",
"browseAll": "모두 보기",
"nextFeatured": "다른 추천 보기",
"cliLabel": "앱", "cliLabel": "앱",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "채널", "channelLabel": "채널",
@@ -603,7 +608,7 @@
"enabledSummary": "{{count}}개 사용 가능", "enabledSummary": "{{count}}개 사용 가능",
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개", "caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
"searchPlaceholder": "앱 검색", "searchPlaceholder": "앱 검색",
"featured": "도구", "featured": "추천",
"mcpTools": "MCP 도구", "mcpTools": "MCP 도구",
"loading": "앱을 불러오는 중...", "loading": "앱을 불러오는 중...",
"empty": "검색과 일치하는 도구가 없습니다.", "empty": "검색과 일치하는 도구가 없습니다.",
+10 -5
View File
@@ -241,7 +241,7 @@
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.", "activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.", "fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.", "codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.", "brandLogos": "Carrega logotipos de marcas de terceiros por serviços externos de ícones. Desative para usar iniciais locais.",
"maxResults": "Resultados retornados por cada chamada de web_search.", "maxResults": "Resultados retornados por cada chamada de web_search.",
"timeout": "Segundos antes de uma requisição de busca expirar.", "timeout": "Segundos antes de uma requisição de busca expirar.",
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.", "jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
@@ -557,9 +557,6 @@
"capabilityOpenAISearch": "Pesquisa web da OpenAI", "capabilityOpenAISearch": "Pesquisa web da OpenAI",
"capabilityOpenAISearchHelp": "Permite que modelos compatíveis com a Responses API pesquisem na web. A atividade de pesquisa aparece no chat." "capabilityOpenAISearchHelp": "Permite que modelos compatíveis com a Responses API pesquisem na web. A atividade de pesquisa aparece no chat."
}, },
"legal": {
"thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso."
},
"image": { "image": {
"selectProvider": "Selecionar provedor", "selectProvider": "Selecionar provedor",
"selectAspect": "Selecionar proporção", "selectAspect": "Selecionar proporção",
@@ -606,6 +603,14 @@
}, },
"apps": { "apps": {
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.", "description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
"discover": "Descobrir",
"installed": "Instalados",
"allApps": "Todos os apps",
"addCustom": "Adicionar personalizado",
"emptyInstalled": "Nenhum app instalado ainda.",
"searchResults": "Resultados da busca",
"browseAll": "Ver todos",
"nextFeatured": "Mostrar outros",
"cliLabel": "Aplicativo", "cliLabel": "Aplicativo",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Canal", "channelLabel": "Canal",
@@ -617,7 +622,7 @@
"enabledSummary": "{{count}} prontos", "enabledSummary": "{{count}} prontos",
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP", "caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
"searchPlaceholder": "Buscar ferramentas", "searchPlaceholder": "Buscar ferramentas",
"featured": "Ferramentas", "featured": "Destaques",
"mcpTools": "Ferramentas MCP", "mcpTools": "Ferramentas MCP",
"loading": "Carregando aplicativos...", "loading": "Carregando aplicativos...",
"empty": "Nenhuma ferramenta corresponde à sua busca.", "empty": "Nenhuma ferramenta corresponde à sua busca.",
+10 -5
View File
@@ -203,7 +203,7 @@
"currentModel": "Dùng cho các phản hồi mới.", "currentModel": "Dùng cho các phản hồi mới.",
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.", "selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Được đặt bởi mô hình đã chọn.", "selectedModelValue": "Được đặt bởi mô hình đã chọn.",
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.", "brandLogos": "Tải logo thương hiệu của bên thứ ba từ dịch vụ biểu tượng bên ngoài. Tắt để dùng chữ cái đại diện cục bộ.",
"cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.", "cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.",
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.", "cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
"logs": "Mở thư mục nhật ký của bộ máy gốc.", "logs": "Mở thư mục nhật ký của bộ máy gốc.",
@@ -587,11 +587,16 @@
"title": "Khả năng quan sát", "configured": "Thông tin xác thực tracing đã sẵn sàng cho nanobot.", "title": "Khả năng quan sát", "configured": "Thông tin xác thực tracing đã sẵn sàng cho nanobot.",
"environment": "Đặt LANGFUSE_SECRET_KEY và LANGFUSE_PUBLIC_KEY rồi khởi động lại nanobot.", "enable": "Bật hỗ trợ tracing" "environment": "Đặt LANGFUSE_SECRET_KEY và LANGFUSE_PUBLIC_KEY rồi khởi động lại nanobot.", "enable": "Bật hỗ trợ tracing"
}, },
"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": { "apps": {
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.", "description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
"discover": "Khám phá",
"installed": "Đã cài đặt",
"allApps": "Tất cả ứng dụng",
"addCustom": "Thêm tùy chỉnh",
"emptyInstalled": "Chưa có ứng dụng nào được cài đặt.",
"searchResults": "Kết quả tìm kiếm",
"browseAll": "Xem tất cả",
"nextFeatured": "Xem nhóm khác",
"cliLabel": "Ứng dụng", "cliLabel": "Ứng dụng",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "Kênh", "channelLabel": "Kênh",
@@ -603,7 +608,7 @@
"enabledSummary": "{{count}} sẵn sàng", "enabledSummary": "{{count}} sẵn sàng",
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP", "caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
"searchPlaceholder": "Tìm ứng dụng", "searchPlaceholder": "Tìm ứng dụng",
"featured": "Công cụ", "featured": "Nổi bật",
"mcpTools": "Công cụ MCP", "mcpTools": "Công cụ MCP",
"loading": "Đang tải ứng dụng...", "loading": "Đang tải ứng dụng...",
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.", "empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
+10 -5
View File
@@ -241,7 +241,7 @@
"activityMode": "选择默认显示多少智能体活动详情。", "activityMode": "选择默认显示多少智能体活动详情。",
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。", "fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
"codeWrap": "让长代码行在小屏幕上也易读。", "codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。", "brandLogos": "从外部图标服务加载第三方品牌 Logo;关闭后使用本地首字母图标。",
"maxResults": "每次 web_search 调用返回的结果数。", "maxResults": "每次 web_search 调用返回的结果数。",
"timeout": "搜索提供商请求超时前等待的秒数。", "timeout": "搜索提供商请求超时前等待的秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。", "jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
@@ -557,9 +557,6 @@
"capabilityOpenAISearch": "OpenAI 联网搜索", "capabilityOpenAISearch": "OpenAI 联网搜索",
"capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。" "capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。"
}, },
"legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
},
"image": { "image": {
"selectProvider": "选择提供商", "selectProvider": "选择提供商",
"selectAspect": "选择比例", "selectAspect": "选择比例",
@@ -606,6 +603,14 @@
}, },
"apps": { "apps": {
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。", "description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
"discover": "发现",
"installed": "已安装",
"allApps": "全部应用",
"addCustom": "自定义接入",
"emptyInstalled": "还没有安装应用。",
"searchResults": "搜索结果",
"browseAll": "浏览全部",
"nextFeatured": "换一批",
"cliLabel": "应用", "cliLabel": "应用",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "渠道", "channelLabel": "渠道",
@@ -617,7 +622,7 @@
"enabledSummary": "{{count}} 个可用", "enabledSummary": "{{count}} 个可用",
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具", "caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
"searchPlaceholder": "搜索工具", "searchPlaceholder": "搜索工具",
"featured": "工具", "featured": "精选",
"mcpTools": "MCP 工具", "mcpTools": "MCP 工具",
"loading": "正在加载应用...", "loading": "正在加载应用...",
"empty": "没有与搜索条件匹配的工具。", "empty": "没有与搜索条件匹配的工具。",
+10 -5
View File
@@ -203,7 +203,7 @@
"currentModel": "用於新的回覆。", "currentModel": "用於新的回覆。",
"selectedModelProvider": "由選取的模型決定。", "selectedModelProvider": "由選取的模型決定。",
"selectedModelValue": "由選取的模型決定。", "selectedModelValue": "由選取的模型決定。",
"brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。", "brandLogos": "從外部圖示服務載入第三方品牌 Logo;關閉後使用本機首字母圖示。",
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。", "cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
"cliAppsFilter": "依應用程式、類別或功能搜尋。", "cliAppsFilter": "依應用程式、類別或功能搜尋。",
"logs": "開啟原生引擎日誌資料夾。", "logs": "開啟原生引擎日誌資料夾。",
@@ -587,11 +587,16 @@
"title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。", "title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。",
"environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援" "environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援"
}, },
"legal": {
"thirdPartyBrands": "產品名稱、Logo 與品牌均為各自擁有者的財產。僅供識別之用,不代表任何形式的背書。"
},
"apps": { "apps": {
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。", "description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
"discover": "探索",
"installed": "已安裝",
"allApps": "全部應用",
"addCustom": "自訂接入",
"emptyInstalled": "尚未安裝應用。",
"searchResults": "搜尋結果",
"browseAll": "瀏覽全部",
"nextFeatured": "換一批",
"cliLabel": "應用程式", "cliLabel": "應用程式",
"mcpLabel": "MCP", "mcpLabel": "MCP",
"channelLabel": "通訊管道", "channelLabel": "通訊管道",
@@ -603,7 +608,7 @@
"enabledSummary": "{{count}} 個就緒", "enabledSummary": "{{count}} 個就緒",
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具", "caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
"searchPlaceholder": "搜尋工具", "searchPlaceholder": "搜尋工具",
"featured": "工具", "featured": "精選",
"mcpTools": "MCP 工具", "mcpTools": "MCP 工具",
"loading": "正在載入應用程式…", "loading": "正在載入應用程式…",
"empty": "沒有符合搜尋條件的工具。", "empty": "沒有符合搜尋條件的工具。",
+13
View File
@@ -1,5 +1,6 @@
import type { import type {
ApiServicePayload, ApiServicePayload,
AppsDiscoveryPayload,
AutomationsPayload, AutomationsPayload,
AutomationUpdatePayload, AutomationUpdatePayload,
ChannelConfigurePayload, ChannelConfigurePayload,
@@ -491,6 +492,18 @@ export async function fetchCliApps(
); );
} }
export async function fetchAppsDiscovery(
token: string,
base: string = "",
): Promise<AppsDiscoveryPayload> {
return request<AppsDiscoveryPayload>(
`${base}/api/settings/apps-discovery`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchInstalledCliApps( export async function fetchInstalledCliApps(
token: string, token: string,
base: string = "", base: string = "",
+10 -4
View File
@@ -12,12 +12,15 @@ export interface LocalPreferences {
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
export const LOCAL_PREFS_CHANGED_EVENT = "nanobot-webui.local-preferences-changed"; export const LOCAL_PREFS_CHANGED_EVENT = "nanobot-webui.local-preferences-changed";
export const LOCAL_PREFS_VERSION = 1;
type StoredLocalPreferences = Partial<LocalPreferences> & { version?: number };
export const DEFAULT_LOCAL_PREFS: LocalPreferences = { export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable", density: "comfortable",
activityMode: "auto", activityMode: "auto",
codeWrap: true, codeWrap: true,
brandLogos: false, brandLogos: true,
fileEditDisplayMode: "summary", fileEditDisplayMode: "summary",
}; };
@@ -29,12 +32,12 @@ export function readLocalPreferences(): LocalPreferences {
try { try {
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY); const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
if (!raw) return DEFAULT_LOCAL_PREFS; if (!raw) return DEFAULT_LOCAL_PREFS;
const parsed = JSON.parse(raw) as Partial<LocalPreferences>; const parsed = JSON.parse(raw) as StoredLocalPreferences;
return { return {
density: parsed.density === "compact" ? "compact" : "comfortable", density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto", activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false, codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos === true, brandLogos: parsed.version === undefined ? true : parsed.brandLogos !== false,
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode), fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
}; };
} catch { } catch {
@@ -44,7 +47,10 @@ export function readLocalPreferences(): LocalPreferences {
export function writeLocalPreferences(preferences: LocalPreferences): void { export function writeLocalPreferences(preferences: LocalPreferences): void {
try { try {
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(preferences)); window.localStorage.setItem(
LOCAL_PREFS_STORAGE_KEY,
JSON.stringify({ version: LOCAL_PREFS_VERSION, ...preferences }),
);
} catch { } catch {
// Browser-only preferences should never block settings. // Browser-only preferences should never block settings.
} }
+7
View File
@@ -817,6 +817,13 @@ export interface CliAppsPayload {
}; };
} }
export interface AppsDiscoveryPayload {
schema_version: number;
updated_at: string;
featured: string[];
refresh_pending?: boolean;
}
export interface NanobotFeatureInfo { export interface NanobotFeatureInfo {
name: string; name: string;
display_name: string; display_name: string;
+1 -1
View File
@@ -2689,7 +2689,7 @@ describe("App layout", () => {
fireEvent.click(appsButton); fireEvent.click(appsButton);
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
expect(screen.queryByText("Add tools to nanobot, then @ them in chat.")).not.toBeInTheDocument(); expect(screen.getByText("Add tools to nanobot, then @ them in chat.")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute( expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_LOCAL_PREFS,
LOCAL_PREFS_STORAGE_KEY,
LOCAL_PREFS_VERSION,
readLocalPreferences,
writeLocalPreferences,
} from "@/lib/local-preferences";
describe("local preferences", () => {
beforeEach(() => localStorage.clear());
it("shows brand logos by default", () => {
expect(DEFAULT_LOCAL_PREFS.brandLogos).toBe(true);
expect(readLocalPreferences().brandLogos).toBe(true);
});
it("preserves an explicit brand-logo opt-out", () => {
localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({
version: LOCAL_PREFS_VERSION,
brandLogos: false,
}));
expect(readLocalPreferences().brandLogos).toBe(false);
});
it("migrates the old auto-persisted opt-out to visible logos", () => {
localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({ brandLogos: false }));
expect(readLocalPreferences().brandLogos).toBe(true);
});
it("enables brand logos for legacy preferences without the field", () => {
localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({ density: "compact" }));
expect(readLocalPreferences().brandLogos).toBe(true);
});
it("versions newly written preferences", () => {
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, brandLogos: false });
expect(JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) ?? "{}")).toMatchObject({
version: LOCAL_PREFS_VERSION,
brandLogos: false,
});
});
});
+15 -20
View File
@@ -103,8 +103,7 @@ describe("SettingsView Apps catalog", () => {
vi.stubGlobal("open", open); vi.stubGlobal("open", open);
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "All apps" }));
expect(screen.getByText("MCP tools")).toBeInTheDocument();
const connectButton = await screen.findByRole("button", { name: "Connect Xmind" }); const connectButton = await screen.findByRole("button", { name: "Connect Xmind" });
expect(connectButton).toHaveTextContent("Connect"); expect(connectButton).toHaveTextContent("Connect");
fireEvent.click(connectButton); fireEvent.click(connectButton);
@@ -167,17 +166,12 @@ describe("SettingsView Apps catalog", () => {
requestMutationMock.mockRejectedValueOnce(new Error("Stopped after request assertion")); requestMutationMock.mockRejectedValueOnce(new Error("Stopped after request assertion"));
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "Ready" })); fireEvent.click(await screen.findByRole("button", { name: "Installed" }));
expect(await screen.findByText("No tools are ready yet.")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Xmind" })).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
const heading = await screen.findByRole("heading", { name: "Xmind" }); const heading = await screen.findByRole("heading", { name: "Xmind" });
const row = heading.closest("article"); const row = heading.closest("article");
expect(row).not.toBeNull(); expect(row).not.toBeNull();
expect(row?.parentElement).toHaveClass("xl:grid-cols-2"); expect(row?.parentElement?.parentElement).toHaveClass("xl:grid-cols-2");
expect(within(row as HTMLElement).queryByText("MCP")).not.toBeInTheDocument(); expect(within(row as HTMLElement).getByText("MCP")).toBeInTheDocument();
const failed = within(row as HTMLElement).getByText("Connection failed."); const failed = within(row as HTMLElement).getByText("Connection failed.");
expect(failed.closest("button")).toBeNull(); expect(failed.closest("button")).toBeNull();
expect(failed.closest("p")?.querySelector(".lucide-triangle-alert")).not.toBeNull(); expect(failed.closest("p")?.querySelector(".lucide-triangle-alert")).not.toBeNull();
@@ -240,7 +234,7 @@ describe("SettingsView Apps catalog", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "All apps" }));
expect(await screen.findByRole("button", { name: "Xmind: Connecting…" })) expect(await screen.findByRole("button", { name: "Xmind: Connecting…" }))
.toHaveTextContent("Connecting…"); .toHaveTextContent("Connecting…");
@@ -251,7 +245,7 @@ describe("SettingsView Apps catalog", () => {
)).toHaveTextContent("Connected."); )).toHaveTextContent("Connected.");
expect(mcpPresetRequests).toBe(2); expect(mcpPresetRequests).toBe(2);
fireEvent.click(screen.getByRole("button", { name: "Ready" })); fireEvent.click(screen.getByRole("button", { name: "Installed" }));
expect(await screen.findByRole("heading", { name: "Xmind" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Xmind" })).toBeInTheDocument();
}); });
@@ -296,7 +290,7 @@ describe("SettingsView Apps catalog", () => {
}); });
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "All apps" }));
expect(await screen.findByText("Connection failed.")).toBeInTheDocument(); expect(await screen.findByText("Connection failed.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reconnect team-docs" })); fireEvent.click(screen.getByRole("button", { name: "Reconnect team-docs" }));
@@ -309,7 +303,7 @@ describe("SettingsView Apps catalog", () => {
const connected = await screen.findByRole("button", { name: "team-docs: Connected." }); const connected = await screen.findByRole("button", { name: "team-docs: Connected." });
expect(connected).toHaveTextContent("Connected."); expect(connected).toHaveTextContent("Connected.");
expect(connected.querySelector(".lucide-check")).not.toBeNull(); expect(connected.querySelector(".lucide-check")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Ready" })); fireEvent.click(screen.getByRole("button", { name: "Installed" }));
const readyHeading = await screen.findByRole("heading", { name: "team-docs" }); const readyHeading = await screen.findByRole("heading", { name: "team-docs" });
expect(within(readyHeading.closest("article") as HTMLElement).getByText("MCP")) expect(within(readyHeading.closest("article") as HTMLElement).getByText("MCP"))
.toBeInTheDocument(); .toBeInTheDocument();
@@ -354,7 +348,7 @@ describe("SettingsView Apps catalog", () => {
}); });
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "Add custom" }));
fireEvent.click(await screen.findByRole("button", { name: "Custom" })); fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
expect(screen.queryByText("Authentication")).not.toBeInTheDocument(); expect(screen.queryByText("Authentication")).not.toBeInTheDocument();
@@ -400,6 +394,7 @@ describe("SettingsView Apps catalog", () => {
expect(values).not.toHaveProperty("headers"); expect(values).not.toHaveProperty("headers");
expect(saveCall?.[2]).toBe(20_000); expect(saveCall?.[2]).toBe(20_000);
}); });
fireEvent.click(screen.getByRole("button", { name: "All apps" }));
expect(await screen.findByRole("button", { name: "Connect team-mcp" })) expect(await screen.findByRole("button", { name: "Connect team-mcp" }))
.toBeInTheDocument(); .toBeInTheDocument();
expect( expect(
@@ -482,7 +477,7 @@ describe("SettingsView Apps catalog", () => {
vi.stubGlobal("open", vi.fn(() => popup)); vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "All apps" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" }); const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" });
@@ -551,7 +546,7 @@ describe("SettingsView Apps catalog", () => {
vi.stubGlobal("open", vi.fn(() => popup)); vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "All apps" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const cancelButton = await screen.findByRole("button", { name: "Cancel" }); const cancelButton = await screen.findByRole("button", { name: "Cancel" });
@@ -609,7 +604,7 @@ describe("SettingsView Apps catalog", () => {
}); });
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "All apps" }));
fireEvent.click(await screen.findByRole("button", { name: "Remove" })); fireEvent.click(await screen.findByRole("button", { name: "Remove" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
@@ -657,7 +652,7 @@ describe("SettingsView Apps catalog", () => {
vi.stubGlobal("open", open); vi.stubGlobal("open", open);
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "All apps" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const continueButton = await screen.findByRole("button", { name: "Continue sign-in" }); const continueButton = await screen.findByRole("button", { name: "Continue sign-in" });
@@ -727,7 +722,7 @@ describe("SettingsView Apps catalog", () => {
vi.stubGlobal("open", vi.fn(() => popup)); vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" })); fireEvent.click(await screen.findByRole("button", { name: "All apps" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 }); await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 });
+5 -15
View File
@@ -3,8 +3,8 @@ import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types"; import type { SettingsPayload } from "@/lib/types";
import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils"; import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
const thirdPartyBrandNotice = const brandLogoHelp =
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."; "Load third-party brand logos from external icon services. Turn this off to use local initials.";
describe("Settings overview and appearance", () => { describe("Settings overview and appearance", () => {
installSettingsViewTestHooks(); installSettingsViewTestHooks();
@@ -26,7 +26,7 @@ describe("Settings overview and appearance", () => {
}); });
}); });
it("shows the third-party brand notice only with the brand logo preference", () => { it("explains the external request behind the brand logo preference", () => {
renderSettingsView({ renderSettingsView({
initialSection: "appearance", initialSection: "appearance",
initialSettings: settingsPayload(), initialSettings: settingsPayload(),
@@ -38,20 +38,10 @@ describe("Settings overview and appearance", () => {
expect(brandLogosRow).not.toBeNull(); expect(brandLogosRow).not.toBeNull();
expect( expect(
within(brandLogosRow as HTMLElement).getByText(thirdPartyBrandNotice), within(brandLogosRow as HTMLElement).getByText(brandLogoHelp),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getAllByText(thirdPartyBrandNotice)).toHaveLength(1);
}); });
it.each(["apps", "channels"] as const)(
"does not repeat the third-party brand notice in %s",
(initialSection) => {
renderSettingsView({ initialSection, initialSettings: settingsPayload() });
expect(screen.queryByText(thirdPartyBrandNotice)).not.toBeInTheDocument();
},
);
it("publishes the latest settings payload to the shell", async () => { it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload(); const payload = settingsPayload();
const onSettingsChange = vi.fn(); const onSettingsChange = vi.fn();
@@ -100,7 +90,7 @@ describe("Settings overview and appearance", () => {
expect(await screen.findByText("No apps available.")).toBeInTheDocument(); expect(await screen.findByText("No apps available.")).toBeInTheDocument();
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument(); expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" })); fireEvent.click(screen.getByRole("button", { name: "Add custom" }));
expect(await screen.findByText("Add MCP server")).toBeInTheDocument(); expect(await screen.findByText("Add MCP server")).toBeInTheDocument();
}); });
+110 -7
View File
@@ -71,6 +71,7 @@ describe("Settings system domains", () => {
renderSettingsView(); renderSettingsView();
fireEvent.click(await screen.findByRole("button", { name: "Installed" }));
expect(await screen.findByText("Computer Use")).toBeInTheDocument(); expect(await screen.findByText("Computer Use")).toBeInTheDocument();
expect(screen.getByText("Plugins")).toBeInTheDocument(); expect(screen.getByText("Plugins")).toBeInTheDocument();
expect(screen.getByText(/Control the desktop.*screen-recording, accessibility/)).toBeInTheDocument(); expect(screen.getByText(/Control the desktop.*screen-recording, accessibility/)).toBeInTheDocument();
@@ -263,6 +264,7 @@ describe("Settings system domains", () => {
renderSettingsView(); renderSettingsView();
expect(screen.queryByRole("heading", { name: "Apps" })).not.toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Apps" })).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: "Installed" }));
expect(await screen.findByText("AnyGen")).toBeInTheDocument(); expect(await screen.findByText("AnyGen")).toBeInTheDocument();
const uninstall = screen.getByRole("button", { name: "Uninstall app" }); const uninstall = screen.getByRole("button", { name: "Uninstall app" });
@@ -282,7 +284,7 @@ describe("Settings system domains", () => {
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument(); expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
}); });
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => { it("opens Apps on a focused Discover surface", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload()); if (url === "/api/settings") return jsonResponse(settingsPayload());
@@ -295,6 +297,13 @@ describe("Settings system domains", () => {
if (url === "/api/settings/mcp-presets") { if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 }); return jsonResponse({ presets: [], installed_count: 0 });
} }
if (url === "/api/settings/apps-discovery") {
return jsonResponse({
schema_version: 1,
updated_at: "2026-08-12T00:00:00Z",
featured: ["cli:anygen"],
});
}
if (url === "/api/settings/nanobot-features") { if (url === "/api/settings/nanobot-features") {
return jsonResponse({ return jsonResponse({
features: [ features: [
@@ -319,17 +328,111 @@ describe("Settings system domains", () => {
renderSettingsView({ initialSection: "apps" }); renderSettingsView({ initialSection: "apps" });
expect(await screen.findByText("AnyGen")).toBeInTheDocument(); expect(await screen.findByText("AnyGen")).toBeInTheDocument();
expect( expect(screen.getByText("Add tools to nanobot, then @ them in chat.")).toBeInTheDocument();
screen.queryByText("Add tools to nanobot, then @ them in chat."), expect(screen.getByRole("button", { name: "Discover" })).toHaveAttribute("aria-pressed", "true");
).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Installed" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false"); expect(screen.getByRole("button", { name: "All apps" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Add custom" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument(); expect(screen.getByText("Featured")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
expect(screen.queryByText("Api")).not.toBeInTheDocument(); expect(screen.queryByText("Api")).not.toBeInTheDocument();
expect(screen.queryByText("0 ready")).not.toBeInTheDocument(); expect(screen.queryByText("0 ready")).not.toBeInTheDocument();
}); });
it("rotates only through the curated Featured candidates", async () => {
const apps = Array.from({ length: 7 }, (_, index) => ({
...installedAnyGen,
name: `app-${index + 1}`,
display_name: `App ${index + 1}`,
installed: false,
status: "available",
}));
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps, installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/apps-discovery") {
return jsonResponse({
schema_version: 1,
updated_at: "2026-08-12T00:00:00Z",
featured: apps.map((app) => `cli:${app.name}`),
});
}
return jsonResponse({});
}));
renderSettingsView({ initialSection: "apps" });
expect(await screen.findByText("App 1")).toBeInTheDocument();
expect(screen.queryByText("App 7")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Show another" }));
expect(await screen.findByText("App 7")).toBeInTheDocument();
expect(screen.queryByText("App 1")).not.toBeInTheDocument();
});
it("does not promote uncurated apps when Featured has fewer than six entries", async () => {
const apps = [1, 2].map((index) => ({
...installedAnyGen,
name: `app-${index}`,
display_name: `App ${index}`,
installed: false,
status: "available",
}));
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps, installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/apps-discovery") {
return jsonResponse({
schema_version: 1,
updated_at: "2026-08-12T00:00:00Z",
featured: ["cli:app-1"],
});
}
return jsonResponse({});
}));
renderSettingsView({ initialSection: "apps" });
expect(await screen.findByText("App 1")).toBeInTheDocument();
expect(screen.queryByText("App 2")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Show another" })).not.toBeInTheDocument();
});
it("clears search before opening custom MCP setup", async () => {
vi.stubGlobal("fetch", 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 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
}));
renderSettingsView({ initialSection: "apps" });
const search = await screen.findByPlaceholderText("Search tools");
fireEvent.change(search, { target: { value: "AnyGen" } });
expect(await screen.findByText("Search results")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Add custom" }));
expect(search).toHaveValue("");
expect(await screen.findByText("Add MCP server")).toBeInTheDocument();
});
it("shows nanobot optional features and enables one", async () => { it("shows nanobot optional features and enables one", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);