mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
feat(webui): add SkillHub marketplace source
This commit is contained in:
parent
42ee34e34d
commit
e66eb204d0
@ -671,10 +671,19 @@ async def test_webui_skills_marketplace_routes_search_and_install(
|
|||||||
"trends": {"acme/agent-skills/react-testing": [2, 4, 3, 8]},
|
"trends": {"acme/agent-skills/react-testing": [2, 4, 3, 8]},
|
||||||
})
|
})
|
||||||
|
|
||||||
async def install(source: str, skill_id: str, workspace: Path) -> dict[str, Any]:
|
async def install(
|
||||||
|
source: str,
|
||||||
|
skill_id: str,
|
||||||
|
workspace: Path,
|
||||||
|
*,
|
||||||
|
provider: str,
|
||||||
|
version: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
assert source == "acme/agent-skills"
|
assert source == "acme/agent-skills"
|
||||||
assert skill_id == "react-testing"
|
assert skill_id == "react-testing"
|
||||||
assert workspace == tmp_path
|
assert workspace == tmp_path
|
||||||
|
assert provider == "skills_sh"
|
||||||
|
assert version == ""
|
||||||
skill_dir = workspace / "skills" / skill_id
|
skill_dir = workspace / "skills" / skill_id
|
||||||
skill_dir.mkdir(parents=True)
|
skill_dir.mkdir(parents=True)
|
||||||
(skill_dir / "SKILL.md").write_text(
|
(skill_dir / "SKILL.md").write_text(
|
||||||
@ -711,7 +720,7 @@ async def test_webui_skills_marketplace_routes_search_and_install(
|
|||||||
)
|
)
|
||||||
assert search_response.status_code == 200
|
assert search_response.status_code == 200
|
||||||
assert search_response.json()["skills"][0]["skill_id"] == "react-testing"
|
assert search_response.json()["skills"][0]["skill_id"] == "react-testing"
|
||||||
search.assert_awaited_once_with("react", tmp_path)
|
search.assert_awaited_once_with("react", tmp_path, provider="all")
|
||||||
|
|
||||||
trending_response = await _http_get(
|
trending_response = await _http_get(
|
||||||
f"http://127.0.0.1:{port}/api/webui/skills/trending",
|
f"http://127.0.0.1:{port}/api/webui/skills/trending",
|
||||||
@ -719,7 +728,7 @@ async def test_webui_skills_marketplace_routes_search_and_install(
|
|||||||
)
|
)
|
||||||
assert trending_response.status_code == 200
|
assert trending_response.status_code == 200
|
||||||
assert trending_response.json()["period"] == "24h"
|
assert trending_response.json()["period"] == "24h"
|
||||||
trending.assert_awaited_once_with(tmp_path)
|
trending.assert_awaited_once_with(tmp_path, provider="all")
|
||||||
|
|
||||||
trends_response = await _http_get(
|
trends_response = await _http_get(
|
||||||
f"http://127.0.0.1:{port}/api/webui/skills/trends"
|
f"http://127.0.0.1:{port}/api/webui/skills/trends"
|
||||||
|
|||||||
@ -1,23 +1,37 @@
|
|||||||
"""Search and install skills from the skills.sh catalog."""
|
"""Search and install skills from public Agent Skills catalogs."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
import zipfile
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
from nanobot.security.network import PinnedDNSAsyncTransport
|
from nanobot.security.network import PinnedDNSAsyncTransport
|
||||||
|
|
||||||
|
_PROVIDER_ALL = "all"
|
||||||
|
_PROVIDER_SKILLS_SH = "skills_sh"
|
||||||
|
_PROVIDER_SKILLHUB = "skillhub"
|
||||||
|
_PROVIDERS = {_PROVIDER_ALL, _PROVIDER_SKILLS_SH, _PROVIDER_SKILLHUB}
|
||||||
_SEARCH_URL = "https://skills.sh/api/search"
|
_SEARCH_URL = "https://skills.sh/api/search"
|
||||||
_TRENDING_URL = "https://skills.sh/api/skills/trending/0"
|
_TRENDING_URL = "https://skills.sh/api/skills/trending/0"
|
||||||
_SKILL_PAGE_BASE_URL = "https://www.skills.sh"
|
_SKILL_PAGE_BASE_URL = "https://www.skills.sh"
|
||||||
|
_SKILLHUB_API_BASE_URL = "https://api.skillhub.cn"
|
||||||
|
_SKILLHUB_SEARCH_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/search"
|
||||||
|
_SKILLHUB_TRENDING_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/showcase/trending"
|
||||||
|
_SKILLHUB_DOWNLOAD_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/download"
|
||||||
|
_SKILLHUB_PAGE_BASE_URL = "https://skillhub.cn"
|
||||||
_ALL_TIME_URLS = (
|
_ALL_TIME_URLS = (
|
||||||
"https://skills.sh/api/skills/all-time/0",
|
"https://skills.sh/api/skills/all-time/0",
|
||||||
"https://skills.sh/api/skills/all-time/1",
|
"https://skills.sh/api/skills/all-time/1",
|
||||||
@ -28,9 +42,13 @@ _SOURCE_RE = re.compile(
|
|||||||
r"[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,98}[A-Za-z0-9])?$"
|
r"[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,98}[A-Za-z0-9])?$"
|
||||||
)
|
)
|
||||||
_SKILL_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
_SKILL_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||||
|
_VERSION_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._+-]{0,63})$")
|
||||||
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||||
_INSTALL_TIMEOUT_SECONDS = 120
|
_INSTALL_TIMEOUT_SECONDS = 120
|
||||||
_WEEKLY_CACHE_TTL_SECONDS = 300
|
_WEEKLY_CACHE_TTL_SECONDS = 300
|
||||||
|
_SKILLHUB_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024
|
||||||
|
_SKILLHUB_MAX_UNPACKED_BYTES = 100 * 1024 * 1024
|
||||||
|
_SKILLHUB_MAX_FILES = 1_000
|
||||||
# The skills CLI's OpenClaw adapter copies into <workspace>/skills, nanobot's layout too.
|
# The skills CLI's OpenClaw adapter copies into <workspace>/skills, nanobot's layout too.
|
||||||
_CLI_AGENT = "openclaw"
|
_CLI_AGENT = "openclaw"
|
||||||
_weekly_cache: dict[tuple[str, str], list[int]] = {}
|
_weekly_cache: dict[tuple[str, str], list[int]] = {}
|
||||||
@ -55,6 +73,43 @@ async def trending_marketplace_skills(
|
|||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
*,
|
*,
|
||||||
limit: int = 8,
|
limit: int = 8,
|
||||||
|
provider: str = _PROVIDER_ALL,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return provider-aware marketplace rankings without mixing metric semantics."""
|
||||||
|
selected = _valid_provider(provider)
|
||||||
|
if selected == _PROVIDER_SKILLHUB:
|
||||||
|
return await _trending_skillhub_skills(workspace_path, limit=limit)
|
||||||
|
if selected == _PROVIDER_SKILLS_SH:
|
||||||
|
return await _trending_skills_sh_skills(workspace_path, limit=limit)
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
_trending_skills_sh_skills(workspace_path, limit=limit),
|
||||||
|
_trending_skillhub_skills(workspace_path, limit=limit),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
payloads = [result for result in results if isinstance(result, dict)]
|
||||||
|
if not payloads:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"skill marketplaces are temporarily unavailable",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"skills": [
|
||||||
|
skill
|
||||||
|
for payload in payloads
|
||||||
|
for skill in payload.get("skills", [])
|
||||||
|
if isinstance(skill, dict)
|
||||||
|
],
|
||||||
|
"period": "mixed",
|
||||||
|
"provider": _PROVIDER_ALL,
|
||||||
|
"install_supported": any(bool(payload.get("install_supported")) for payload in payloads),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _trending_skills_sh_skills(
|
||||||
|
workspace_path: Path,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return a source-diverse snapshot of skills.sh's real 24-hour leaderboard."""
|
"""Return a source-diverse snapshot of skills.sh's real 24-hour leaderboard."""
|
||||||
try:
|
try:
|
||||||
@ -89,6 +144,7 @@ async def trending_marketplace_skills(
|
|||||||
return {
|
return {
|
||||||
"skills": skills,
|
"skills": skills,
|
||||||
"period": "24h",
|
"period": "24h",
|
||||||
|
"provider": _PROVIDER_SKILLS_SH,
|
||||||
"install_supported": skills_install_supported(),
|
"install_supported": skills_install_supported(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,14 +154,51 @@ async def search_marketplace_skills(
|
|||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
*,
|
*,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
|
provider: str = _PROVIDER_ALL,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Search skills.sh and annotate results already installed in this workspace."""
|
"""Search one or all catalogs and annotate locally installed results."""
|
||||||
normalized = " ".join(query.split())
|
normalized = " ".join(query.split())
|
||||||
if len(normalized) < 2:
|
if len(normalized) < 2:
|
||||||
raise SkillsMarketplaceError("search query must contain at least 2 characters")
|
raise SkillsMarketplaceError("search query must contain at least 2 characters")
|
||||||
if len(normalized) > 100:
|
if len(normalized) > 100:
|
||||||
raise SkillsMarketplaceError("search query is too long")
|
raise SkillsMarketplaceError("search query is too long")
|
||||||
|
|
||||||
|
selected = _valid_provider(provider)
|
||||||
|
if selected == _PROVIDER_SKILLHUB:
|
||||||
|
return await _search_skillhub_skills(normalized, workspace_path, limit=limit)
|
||||||
|
if selected == _PROVIDER_SKILLS_SH:
|
||||||
|
return await _search_skills_sh_skills(normalized, workspace_path, limit=limit)
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
_search_skills_sh_skills(normalized, workspace_path, limit=limit),
|
||||||
|
_search_skillhub_skills(normalized, workspace_path, limit=limit),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
payloads = [result for result in results if isinstance(result, dict)]
|
||||||
|
if not payloads:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"skill marketplaces are temporarily unavailable",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"query": normalized,
|
||||||
|
"skills": [
|
||||||
|
skill
|
||||||
|
for payload in payloads
|
||||||
|
for skill in payload.get("skills", [])
|
||||||
|
if isinstance(skill, dict)
|
||||||
|
],
|
||||||
|
"provider": _PROVIDER_ALL,
|
||||||
|
"install_supported": any(bool(payload.get("install_supported")) for payload in payloads),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_skills_sh_skills(
|
||||||
|
normalized: str,
|
||||||
|
workspace_path: Path,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
async with _skills_client() as client:
|
async with _skills_client() as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@ -133,10 +226,82 @@ async def search_marketplace_skills(
|
|||||||
return {
|
return {
|
||||||
"query": normalized,
|
"query": normalized,
|
||||||
"skills": skills,
|
"skills": skills,
|
||||||
|
"provider": _PROVIDER_SKILLS_SH,
|
||||||
"install_supported": skills_install_supported(),
|
"install_supported": skills_install_supported(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_skillhub_skills(
|
||||||
|
normalized: str,
|
||||||
|
workspace_path: Path,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
async with _skillhub_client() as client:
|
||||||
|
response = await client.get(
|
||||||
|
_SKILLHUB_SEARCH_URL,
|
||||||
|
params={"q": normalized, "limit": min(max(limit, 1), 50)},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub search is temporarily unavailable",
|
||||||
|
status=502,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
installed = _installed_skill_names(workspace_path)
|
||||||
|
rows = payload.get("results", []) if isinstance(payload, dict) else []
|
||||||
|
skills = [
|
||||||
|
skill
|
||||||
|
for row in rows
|
||||||
|
if isinstance(row, dict)
|
||||||
|
if (skill := _skillhub_skill(row, installed)) is not None
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"query": normalized,
|
||||||
|
"skills": skills,
|
||||||
|
"provider": _PROVIDER_SKILLHUB,
|
||||||
|
"install_supported": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _trending_skillhub_skills(
|
||||||
|
workspace_path: Path,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
async with _skillhub_client() as client:
|
||||||
|
response = await client.get(_SKILLHUB_TRENDING_URL)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub trending skills are temporarily unavailable",
|
||||||
|
status=502,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
installed = _installed_skill_names(workspace_path)
|
||||||
|
rows = payload.get("skills", []) if isinstance(payload, dict) else []
|
||||||
|
skills: list[dict[str, Any]] = []
|
||||||
|
for rank, row in enumerate(rows, start=1):
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
skill = _skillhub_skill(row, installed, rank=rank)
|
||||||
|
if skill is not None:
|
||||||
|
skills.append(skill)
|
||||||
|
if len(skills) >= min(max(limit, 1), 20):
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"skills": skills,
|
||||||
|
"period": "trending",
|
||||||
|
"provider": _PROVIDER_SKILLHUB,
|
||||||
|
"install_supported": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def marketplace_skill_trends(
|
async def marketplace_skill_trends(
|
||||||
skill_ids: list[str] | None = None,
|
skill_ids: list[str] | None = None,
|
||||||
) -> dict[str, dict[str, list[int]]]:
|
) -> dict[str, dict[str, list[int]]]:
|
||||||
@ -162,18 +327,29 @@ async def install_marketplace_skill(
|
|||||||
source: str,
|
source: str,
|
||||||
skill_id: str,
|
skill_id: str,
|
||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
|
*,
|
||||||
|
provider: str = _PROVIDER_SKILLS_SH,
|
||||||
|
version: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Install one normalized marketplace result into ``<workspace>/skills``."""
|
||||||
|
selected = _valid_provider(provider, allow_all=False)
|
||||||
|
if selected == _PROVIDER_SKILLHUB:
|
||||||
|
return await _install_skillhub_skill(skill_id, version, workspace_path)
|
||||||
|
return await _install_skills_sh_skill(source, skill_id, workspace_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def _install_skills_sh_skill(
|
||||||
|
source: str,
|
||||||
|
skill_id: str,
|
||||||
|
workspace_path: Path,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Install one skills.sh result into ``<workspace>/skills``."""
|
|
||||||
if not _SOURCE_RE.fullmatch(source):
|
if not _SOURCE_RE.fullmatch(source):
|
||||||
raise SkillsMarketplaceError("invalid skill source")
|
raise SkillsMarketplaceError("invalid skill source")
|
||||||
if not _valid_skill_id(skill_id):
|
if not _valid_skill_id(skill_id):
|
||||||
raise SkillsMarketplaceError("invalid skill name")
|
raise SkillsMarketplaceError("invalid skill name")
|
||||||
|
|
||||||
loader = SkillsLoader(workspace_path)
|
loader = SkillsLoader(workspace_path)
|
||||||
existing = {
|
existing = {entry["name"]: entry for entry in loader.list_skills(filter_unavailable=False)}
|
||||||
entry["name"]: entry
|
|
||||||
for entry in loader.list_skills(filter_unavailable=False)
|
|
||||||
}
|
|
||||||
if skill_id in existing:
|
if skill_id in existing:
|
||||||
return {"installed": True, "already_installed": True, "name": skill_id}
|
return {"installed": True, "already_installed": True, "name": skill_id}
|
||||||
|
|
||||||
@ -242,6 +418,287 @@ async def install_marketplace_skill(
|
|||||||
return {"installed": True, "already_installed": False, "name": skill_id}
|
return {"installed": True, "already_installed": False, "name": skill_id}
|
||||||
|
|
||||||
|
|
||||||
|
async def _install_skillhub_skill(
|
||||||
|
skill_id: str,
|
||||||
|
requested_version: str,
|
||||||
|
workspace_path: Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not _valid_skill_id(skill_id):
|
||||||
|
raise SkillsMarketplaceError("invalid SkillHub skill name")
|
||||||
|
if requested_version and _VERSION_RE.fullmatch(requested_version) is None:
|
||||||
|
raise SkillsMarketplaceError("invalid SkillHub skill version")
|
||||||
|
|
||||||
|
loader = SkillsLoader(workspace_path)
|
||||||
|
existing = {entry["name"]: entry for entry in loader.list_skills(filter_unavailable=False)}
|
||||||
|
if skill_id in existing:
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"already_installed": True,
|
||||||
|
"name": skill_id,
|
||||||
|
"provider": _PROVIDER_SKILLHUB,
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace = workspace_path.expanduser().resolve()
|
||||||
|
skills_root = workspace / "skills"
|
||||||
|
skills_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = skills_root / skill_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with _skillhub_client() as client:
|
||||||
|
version = requested_version or await _skillhub_latest_version(client, skill_id)
|
||||||
|
signature = await _skillhub_signature(client, skill_id, version)
|
||||||
|
expected_hash = signature.get("content_hash")
|
||||||
|
if not isinstance(expected_hash, str) or not re.fullmatch(
|
||||||
|
r"[0-9a-fA-F]{64}",
|
||||||
|
expected_hash,
|
||||||
|
):
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub did not provide a valid package fingerprint",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix=".skillhub-install-",
|
||||||
|
dir=skills_root,
|
||||||
|
) as temporary:
|
||||||
|
temporary_path = Path(temporary)
|
||||||
|
archive_path = temporary_path / f"{skill_id}.zip"
|
||||||
|
stage_path = temporary_path / "stage"
|
||||||
|
await _download_skillhub_archive(
|
||||||
|
client,
|
||||||
|
skill_id,
|
||||||
|
version,
|
||||||
|
archive_path,
|
||||||
|
)
|
||||||
|
actual_hash = _validate_skillhub_archive(archive_path)
|
||||||
|
if actual_hash.lower() != expected_hash.lower():
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub package fingerprint did not match",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
_extract_skillhub_archive(archive_path, stage_path)
|
||||||
|
if target.exists():
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"already_installed": True,
|
||||||
|
"name": skill_id,
|
||||||
|
"provider": _PROVIDER_SKILLHUB,
|
||||||
|
}
|
||||||
|
os.replace(stage_path, target)
|
||||||
|
except SkillsMarketplaceError:
|
||||||
|
raise
|
||||||
|
except (httpx.HTTPError, OSError, zipfile.BadZipFile) as exc:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub skill installation failed",
|
||||||
|
status=502,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
installed = next(
|
||||||
|
(
|
||||||
|
entry
|
||||||
|
for entry in loader.list_skills(filter_unavailable=False)
|
||||||
|
if entry["source"] == "workspace" and entry["name"] == skill_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if installed is None:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"installer completed but the skill was not found in this workspace",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"already_installed": False,
|
||||||
|
"name": skill_id,
|
||||||
|
"provider": _PROVIDER_SKILLHUB,
|
||||||
|
"version": version,
|
||||||
|
"verified": bool(signature.get("signed")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _skillhub_latest_version(client: httpx.AsyncClient, skill_id: str) -> str:
|
||||||
|
response = await client.get(
|
||||||
|
f"{_SKILLHUB_API_BASE_URL}/api/v1/skills/{quote(skill_id, safe='')}"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
latest = payload.get("latestVersion", {}) if isinstance(payload, dict) else {}
|
||||||
|
version = latest.get("version") if isinstance(latest, dict) else None
|
||||||
|
if not isinstance(version, str) or _VERSION_RE.fullmatch(version) is None:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub did not return a valid skill version",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
async def _skillhub_signature(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
skill_id: str,
|
||||||
|
version: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
response = await client.get(
|
||||||
|
f"{_SKILLHUB_API_BASE_URL}/api/v1/open/skills/"
|
||||||
|
f"{quote(skill_id, safe='')}/versions/{quote(version, safe='')}/signature"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub returned an invalid package fingerprint",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_skillhub_archive(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
skill_id: str,
|
||||||
|
version: str,
|
||||||
|
destination: Path,
|
||||||
|
) -> None:
|
||||||
|
redirect = await client.get(
|
||||||
|
_SKILLHUB_DOWNLOAD_URL,
|
||||||
|
params={"slug": skill_id, "version": version},
|
||||||
|
)
|
||||||
|
if redirect.status_code not in {301, 302, 303, 307, 308}:
|
||||||
|
redirect.raise_for_status()
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub returned an unexpected download response",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
location = redirect.headers.get("location", "")
|
||||||
|
if not _valid_skillhub_download_url(location):
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub returned an unsafe download location",
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
received = 0
|
||||||
|
async with client.stream(
|
||||||
|
"GET",
|
||||||
|
location,
|
||||||
|
headers={"Accept": "application/zip,application/octet-stream"},
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
declared = response.headers.get("content-length")
|
||||||
|
if declared and declared.isdigit() and int(declared) > _SKILLHUB_MAX_DOWNLOAD_BYTES:
|
||||||
|
raise SkillsMarketplaceError("SkillHub package is too large", status=413)
|
||||||
|
with destination.open("wb") as output:
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
received += len(chunk)
|
||||||
|
if received > _SKILLHUB_MAX_DOWNLOAD_BYTES:
|
||||||
|
raise SkillsMarketplaceError("SkillHub package is too large", status=413)
|
||||||
|
output.write(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_skillhub_download_url(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
parsed.scheme == "https"
|
||||||
|
and parsed.username is None
|
||||||
|
and parsed.password is None
|
||||||
|
and port in {None, 443}
|
||||||
|
and hostname.endswith(".myqcloud.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_skillhub_entries(
|
||||||
|
archive: zipfile.ZipFile,
|
||||||
|
) -> list[tuple[zipfile.ZipInfo, str]]:
|
||||||
|
entries: list[tuple[zipfile.ZipInfo, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
unpacked = 0
|
||||||
|
for info in archive.infolist():
|
||||||
|
raw_name = info.filename.replace("\\", "/")
|
||||||
|
path = PurePosixPath(raw_name)
|
||||||
|
normalized = path.as_posix()
|
||||||
|
mode = info.external_attr >> 16
|
||||||
|
kind = stat.S_IFMT(mode)
|
||||||
|
if (
|
||||||
|
not normalized
|
||||||
|
or "\x00" in normalized
|
||||||
|
or path.is_absolute()
|
||||||
|
or ".." in path.parts
|
||||||
|
or (path.parts and ":" in path.parts[0])
|
||||||
|
or kind == stat.S_IFLNK
|
||||||
|
or kind not in {0, stat.S_IFREG, stat.S_IFDIR}
|
||||||
|
):
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
f"SkillHub package contains an unsafe path: {raw_name}",
|
||||||
|
status=422,
|
||||||
|
)
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
if normalized in seen:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
f"SkillHub package contains a duplicate path: {normalized}",
|
||||||
|
status=422,
|
||||||
|
)
|
||||||
|
seen.add(normalized)
|
||||||
|
unpacked += info.file_size
|
||||||
|
if len(entries) >= _SKILLHUB_MAX_FILES:
|
||||||
|
raise SkillsMarketplaceError("SkillHub package contains too many files", status=413)
|
||||||
|
if unpacked > _SKILLHUB_MAX_UNPACKED_BYTES:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub package expands beyond the size limit", status=413
|
||||||
|
)
|
||||||
|
entries.append((info, normalized))
|
||||||
|
if "SKILL.md" not in seen:
|
||||||
|
raise SkillsMarketplaceError(
|
||||||
|
"SkillHub package does not contain a root SKILL.md",
|
||||||
|
status=422,
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_skillhub_archive(archive_path: Path) -> str:
|
||||||
|
hashed: list[tuple[str, str]] = []
|
||||||
|
with zipfile.ZipFile(archive_path, "r") as archive:
|
||||||
|
for info, normalized in _validated_skillhub_entries(archive):
|
||||||
|
if _skillhub_hash_ignored(normalized):
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with archive.open(info, "r") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
hashed.append((normalized, digest.hexdigest()))
|
||||||
|
combined = hashlib.sha256()
|
||||||
|
for normalized, digest in sorted(hashed):
|
||||||
|
combined.update(f"{normalized}:{digest}\n".encode())
|
||||||
|
return combined.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _skillhub_hash_ignored(path: str) -> bool:
|
||||||
|
parts = PurePosixPath(path).parts
|
||||||
|
basename = parts[-1] if parts else ""
|
||||||
|
return (
|
||||||
|
path == "_meta.json"
|
||||||
|
or "__MACOSX" in parts
|
||||||
|
or basename == ".DS_Store"
|
||||||
|
or basename.startswith("._")
|
||||||
|
or basename.lower() == "thumbs.db"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_skillhub_archive(archive_path: Path, destination: Path) -> None:
|
||||||
|
destination.mkdir()
|
||||||
|
with zipfile.ZipFile(archive_path, "r") as archive:
|
||||||
|
for info, normalized in _validated_skillhub_entries(archive):
|
||||||
|
target = destination.joinpath(*PurePosixPath(normalized).parts)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with archive.open(info, "r") as source, target.open("wb") as output:
|
||||||
|
shutil.copyfileobj(source, output)
|
||||||
|
mode = (info.external_attr >> 16) & 0o777
|
||||||
|
if mode:
|
||||||
|
target.chmod(mode & 0o755)
|
||||||
|
|
||||||
|
|
||||||
def _skills_client() -> httpx.AsyncClient:
|
def _skills_client() -> httpx.AsyncClient:
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
transport=PinnedDNSAsyncTransport(),
|
transport=PinnedDNSAsyncTransport(),
|
||||||
@ -250,6 +707,14 @@ def _skills_client() -> httpx.AsyncClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _skillhub_client() -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=PinnedDNSAsyncTransport(),
|
||||||
|
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _installed_skill_names(workspace_path: Path) -> set[str]:
|
def _installed_skill_names(workspace_path: Path) -> set[str]:
|
||||||
return {
|
return {
|
||||||
entry["name"]
|
entry["name"]
|
||||||
@ -278,9 +743,66 @@ def _marketplace_skill(
|
|||||||
"skill_id": skill_id,
|
"skill_id": skill_id,
|
||||||
"name": display_name.strip(),
|
"name": display_name.strip(),
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"provider": _PROVIDER_SKILLS_SH,
|
||||||
"installs": installs if isinstance(installs, int) and installs >= 0 else 0,
|
"installs": installs if isinstance(installs, int) and installs >= 0 else 0,
|
||||||
"url": f"https://skills.sh/{source}/{skill_id}",
|
"url": f"https://skills.sh/{source}/{skill_id}",
|
||||||
"installed": skill_id in installed,
|
"installed": skill_id in installed,
|
||||||
|
"install_supported": skills_install_supported(),
|
||||||
|
"metric": "installs_24h" if rank is not None else "installs_total",
|
||||||
|
}
|
||||||
|
if rank is not None:
|
||||||
|
skill["rank"] = rank
|
||||||
|
return skill
|
||||||
|
|
||||||
|
|
||||||
|
def _skillhub_skill(
|
||||||
|
row: dict[str, Any],
|
||||||
|
installed: set[str],
|
||||||
|
*,
|
||||||
|
rank: int | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
skill_id = row.get("slug")
|
||||||
|
if not isinstance(skill_id, str) or not _valid_skill_id(skill_id):
|
||||||
|
return None
|
||||||
|
display_name = row.get("displayName") or row.get("name") or skill_id
|
||||||
|
if not isinstance(display_name, str) or not display_name.strip():
|
||||||
|
display_name = skill_id
|
||||||
|
|
||||||
|
namespace = row.get("namespace")
|
||||||
|
handle = namespace.get("handle") if isinstance(namespace, dict) else None
|
||||||
|
if not isinstance(handle, str) or not handle.strip():
|
||||||
|
owner = row.get("owner_name") or row.get("ownerName")
|
||||||
|
handle = owner if isinstance(owner, str) and owner.strip() else "community"
|
||||||
|
source = f"@{handle.strip()}/{skill_id}"
|
||||||
|
|
||||||
|
installs = row.get("installs")
|
||||||
|
downloads = row.get("downloads")
|
||||||
|
publisher = row.get("publisher")
|
||||||
|
verified = bool(isinstance(publisher, dict) and publisher.get("verified") is True)
|
||||||
|
labels = row.get("labels")
|
||||||
|
requires_api_key = bool(
|
||||||
|
isinstance(labels, dict) and str(labels.get("requires_api_key", "")).lower() == "true"
|
||||||
|
)
|
||||||
|
version = row.get("version")
|
||||||
|
if not isinstance(version, str) or _VERSION_RE.fullmatch(version) is None:
|
||||||
|
version = ""
|
||||||
|
|
||||||
|
skill: dict[str, Any] = {
|
||||||
|
"id": f"{_PROVIDER_SKILLHUB}:{skill_id}",
|
||||||
|
"skill_id": skill_id,
|
||||||
|
"name": display_name.strip(),
|
||||||
|
"source": source,
|
||||||
|
"provider": _PROVIDER_SKILLHUB,
|
||||||
|
"installs": installs if isinstance(installs, int) and installs >= 0 else 0,
|
||||||
|
"downloads": downloads if isinstance(downloads, int) and downloads >= 0 else 0,
|
||||||
|
"url": f"{_SKILLHUB_PAGE_BASE_URL}/{quote(handle.strip(), safe='')}/"
|
||||||
|
f"{quote(skill_id, safe='')}",
|
||||||
|
"installed": skill_id in installed,
|
||||||
|
"install_supported": True,
|
||||||
|
"metric": "installs_total",
|
||||||
|
"version": version,
|
||||||
|
"verified": verified,
|
||||||
|
"requires_api_key": requires_api_key,
|
||||||
}
|
}
|
||||||
if rank is not None:
|
if rank is not None:
|
||||||
skill["rank"] = rank
|
skill["rank"] = rank
|
||||||
@ -318,11 +840,7 @@ async def _load_weekly_installs(
|
|||||||
source = row.get("source")
|
source = row.get("source")
|
||||||
skill_id = row.get("skillId")
|
skill_id = row.get("skillId")
|
||||||
values = row.get("weeklyInstalls")
|
values = row.get("weeklyInstalls")
|
||||||
if (
|
if isinstance(source, str) and isinstance(skill_id, str) and isinstance(values, list):
|
||||||
isinstance(source, str)
|
|
||||||
and isinstance(skill_id, str)
|
|
||||||
and isinstance(values, list)
|
|
||||||
):
|
|
||||||
clean = [
|
clean = [
|
||||||
value
|
value
|
||||||
for value in values
|
for value in values
|
||||||
@ -344,11 +862,7 @@ def _valid_skill_refs(skill_ids: list[str]) -> list[tuple[str, str]]:
|
|||||||
continue
|
continue
|
||||||
source, skill_id = value.rsplit("/", 1)
|
source, skill_id = value.rsplit("/", 1)
|
||||||
ref = (source, skill_id)
|
ref = (source, skill_id)
|
||||||
if (
|
if _SOURCE_RE.fullmatch(source) and _valid_skill_id(skill_id) and ref not in refs:
|
||||||
_SOURCE_RE.fullmatch(source)
|
|
||||||
and _valid_skill_id(skill_id)
|
|
||||||
and ref not in refs
|
|
||||||
):
|
|
||||||
refs.append(ref)
|
refs.append(ref)
|
||||||
return refs
|
return refs
|
||||||
|
|
||||||
@ -363,9 +877,7 @@ async def _load_skill_page_trends(
|
|||||||
source, skill_id = ref
|
source, skill_id = ref
|
||||||
try:
|
try:
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
response = await client.get(
|
response = await client.get(f"{_SKILL_PAGE_BASE_URL}/{source}/{skill_id}")
|
||||||
f"{_SKILL_PAGE_BASE_URL}/{source}/{skill_id}"
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
return ref, []
|
return ref, []
|
||||||
@ -373,11 +885,7 @@ async def _load_skill_page_trends(
|
|||||||
match = _TREND_VALUES_RE.search(response.text)
|
match = _TREND_VALUES_RE.search(response.text)
|
||||||
if match is None:
|
if match is None:
|
||||||
return ref, []
|
return ref, []
|
||||||
values = [
|
values = [int(value) for value in match.group(1).split(",") if value.strip()]
|
||||||
int(value)
|
|
||||||
for value in match.group(1).split(",")
|
|
||||||
if value.strip()
|
|
||||||
]
|
|
||||||
return ref, values if len(values) >= 2 else []
|
return ref, values if len(values) >= 2 else []
|
||||||
|
|
||||||
return dict(await asyncio.gather(*(fetch(ref) for ref in refs)))
|
return dict(await asyncio.gather(*(fetch(ref) for ref in refs)))
|
||||||
@ -387,6 +895,14 @@ def _valid_skill_id(value: str) -> bool:
|
|||||||
return len(value) <= 64 and _SKILL_RE.fullmatch(value) is not None
|
return len(value) <= 64 and _SKILL_RE.fullmatch(value) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_provider(value: str, *, allow_all: bool = True) -> str:
|
||||||
|
normalized = value.strip().lower() or _PROVIDER_ALL
|
||||||
|
allowed = _PROVIDERS if allow_all else _PROVIDERS - {_PROVIDER_ALL}
|
||||||
|
if normalized not in allowed:
|
||||||
|
raise SkillsMarketplaceError("invalid skill marketplace provider")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _safe_output_tail(output: bytes | None) -> str:
|
def _safe_output_tail(output: bytes | None) -> str:
|
||||||
if not output:
|
if not output:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -860,26 +860,36 @@ class GatewayHTTPHandler:
|
|||||||
async def _handle_webui_skills_search(self, request: WsRequest) -> Response:
|
async def _handle_webui_skills_search(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
query = _query_first(_parse_query(request.path), "q") or ""
|
params = _parse_query(request.path)
|
||||||
|
query = _query_first(params, "q") or ""
|
||||||
|
provider = _query_first(params, "provider") or "all"
|
||||||
try:
|
try:
|
||||||
payload = await search_marketplace_skills(query, self.skills_workspace_path)
|
payload = await search_marketplace_skills(
|
||||||
|
query,
|
||||||
|
self.skills_workspace_path,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
except SkillsMarketplaceError as exc:
|
except SkillsMarketplaceError as exc:
|
||||||
return _http_error(exc.status, exc.message)
|
return _http_error(exc.status, exc.message)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._log.exception("skills.sh search failed")
|
self._log.exception("skills marketplace search failed")
|
||||||
return _http_error(500, "skills.sh search failed")
|
return _http_error(500, "skills marketplace search failed")
|
||||||
return _http_json_response(payload)
|
return _http_json_response(payload)
|
||||||
|
|
||||||
async def _handle_webui_skills_trending(self, request: WsRequest) -> Response:
|
async def _handle_webui_skills_trending(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
|
provider = _query_first(_parse_query(request.path), "provider") or "all"
|
||||||
try:
|
try:
|
||||||
payload = await trending_marketplace_skills(self.skills_workspace_path)
|
payload = await trending_marketplace_skills(
|
||||||
|
self.skills_workspace_path,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
except SkillsMarketplaceError as exc:
|
except SkillsMarketplaceError as exc:
|
||||||
return _http_error(exc.status, exc.message)
|
return _http_error(exc.status, exc.message)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._log.exception("skills.sh trending lookup failed")
|
self._log.exception("skills marketplace trending lookup failed")
|
||||||
return _http_error(500, "skills.sh trending lookup failed")
|
return _http_error(500, "skills marketplace trending lookup failed")
|
||||||
return _http_json_response(payload)
|
return _http_json_response(payload)
|
||||||
|
|
||||||
async def _handle_webui_skill_trends(self, request: WsRequest) -> Response:
|
async def _handle_webui_skill_trends(self, request: WsRequest) -> Response:
|
||||||
@ -904,13 +914,17 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(403, "remote skill installation is disabled")
|
return _http_error(403, "remote skill installation is disabled")
|
||||||
|
|
||||||
query = _parse_query(request.path)
|
query = _parse_query(request.path)
|
||||||
|
provider = _query_first(query, "provider") or "skills_sh"
|
||||||
source = _query_first(query, "source") or ""
|
source = _query_first(query, "source") or ""
|
||||||
skill_id = _query_first(query, "skill") or ""
|
skill_id = _query_first(query, "skill") or ""
|
||||||
|
version = _query_first(query, "version") or ""
|
||||||
try:
|
try:
|
||||||
action = await install_marketplace_skill(
|
action = await install_marketplace_skill(
|
||||||
source,
|
source,
|
||||||
skill_id,
|
skill_id,
|
||||||
self.skills_workspace_path,
|
self.skills_workspace_path,
|
||||||
|
provider=provider,
|
||||||
|
version=version,
|
||||||
)
|
)
|
||||||
except SkillsMarketplaceError as exc:
|
except SkillsMarketplaceError as exc:
|
||||||
return _http_error(exc.status, exc.message)
|
return _http_error(exc.status, exc.message)
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -6,6 +9,8 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.webui.skills_marketplace import (
|
from nanobot.webui.skills_marketplace import (
|
||||||
SkillsMarketplaceError,
|
SkillsMarketplaceError,
|
||||||
|
_valid_skillhub_download_url,
|
||||||
|
_validated_skillhub_entries,
|
||||||
install_marketplace_skill,
|
install_marketplace_skill,
|
||||||
marketplace_skill_trends,
|
marketplace_skill_trends,
|
||||||
search_marketplace_skills,
|
search_marketplace_skills,
|
||||||
@ -60,7 +65,11 @@ async def test_search_marketplace_skills_filters_and_marks_installed(
|
|||||||
"nanobot.webui.skills_marketplace.skills_install_supported",
|
"nanobot.webui.skills_marketplace.skills_install_supported",
|
||||||
lambda: True,
|
lambda: True,
|
||||||
)
|
)
|
||||||
payload = await search_marketplace_skills(" react testing ", tmp_path)
|
payload = await search_marketplace_skills(
|
||||||
|
" react testing ",
|
||||||
|
tmp_path,
|
||||||
|
provider="skills_sh",
|
||||||
|
)
|
||||||
|
|
||||||
assert seen == {
|
assert seen == {
|
||||||
"url": "https://skills.sh/api/search",
|
"url": "https://skills.sh/api/search",
|
||||||
@ -68,6 +77,7 @@ async def test_search_marketplace_skills_filters_and_marks_installed(
|
|||||||
}
|
}
|
||||||
assert payload == {
|
assert payload == {
|
||||||
"query": "react testing",
|
"query": "react testing",
|
||||||
|
"provider": "skills_sh",
|
||||||
"install_supported": True,
|
"install_supported": True,
|
||||||
"skills": [
|
"skills": [
|
||||||
{
|
{
|
||||||
@ -75,14 +85,92 @@ async def test_search_marketplace_skills_filters_and_marks_installed(
|
|||||||
"skill_id": "react-testing",
|
"skill_id": "react-testing",
|
||||||
"name": "React Testing",
|
"name": "React Testing",
|
||||||
"source": "acme/agent-skills",
|
"source": "acme/agent-skills",
|
||||||
|
"provider": "skills_sh",
|
||||||
"installs": 42,
|
"installs": 42,
|
||||||
"url": "https://skills.sh/acme/agent-skills/react-testing",
|
"url": "https://skills.sh/acme/agent-skills/react-testing",
|
||||||
"installed": True,
|
"installed": True,
|
||||||
|
"install_supported": True,
|
||||||
|
"metric": "installs_total",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_skillhub_skills_normalizes_provider_metadata(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
seen: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"slug": "ima-skills",
|
||||||
|
"name": "ima-skills",
|
||||||
|
"namespace": {"handle": "tencent-adm"},
|
||||||
|
"source": "enterprise",
|
||||||
|
"version": "1.1.8",
|
||||||
|
"installs": 11831,
|
||||||
|
"downloads": 142525,
|
||||||
|
"publisher": {"verified": True},
|
||||||
|
"labels": {"requires_api_key": "true"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def __aenter__(self) -> "FakeClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get(self, url: str, *, params: dict[str, object]) -> FakeResponse:
|
||||||
|
seen.update(url=url, params=params)
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
|
||||||
|
lambda **_kwargs: FakeClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await search_marketplace_skills(
|
||||||
|
" ima ",
|
||||||
|
tmp_path,
|
||||||
|
provider="skillhub",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert seen == {
|
||||||
|
"url": "https://api.skillhub.cn/api/v1/search",
|
||||||
|
"params": {"q": "ima", "limit": 20},
|
||||||
|
}
|
||||||
|
assert payload["provider"] == "skillhub"
|
||||||
|
assert payload["skills"] == [
|
||||||
|
{
|
||||||
|
"id": "skillhub:ima-skills",
|
||||||
|
"skill_id": "ima-skills",
|
||||||
|
"name": "ima-skills",
|
||||||
|
"source": "@tencent-adm/ima-skills",
|
||||||
|
"provider": "skillhub",
|
||||||
|
"installs": 11831,
|
||||||
|
"downloads": 142525,
|
||||||
|
"url": "https://skillhub.cn/tencent-adm/ima-skills",
|
||||||
|
"installed": False,
|
||||||
|
"install_supported": True,
|
||||||
|
"metric": "installs_total",
|
||||||
|
"version": "1.1.8",
|
||||||
|
"verified": True,
|
||||||
|
"requires_api_key": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_trending_marketplace_skills_diversifies_sources_and_keeps_rank(
|
async def test_trending_marketplace_skills_diversifies_sources_and_keeps_rank(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
@ -131,7 +219,7 @@ async def test_trending_marketplace_skills_diversifies_sources_and_keeps_rank(
|
|||||||
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
|
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
|
||||||
lambda **_kwargs: FakeClient(),
|
lambda **_kwargs: FakeClient(),
|
||||||
)
|
)
|
||||||
payload = await trending_marketplace_skills(tmp_path)
|
payload = await trending_marketplace_skills(tmp_path, provider="skills_sh")
|
||||||
|
|
||||||
assert payload["period"] == "24h"
|
assert payload["period"] == "24h"
|
||||||
assert [(skill["name"], skill["rank"]) for skill in payload["skills"]] == [
|
assert [(skill["name"], skill["rank"]) for skill in payload["skills"]] == [
|
||||||
@ -145,7 +233,7 @@ async def test_marketplace_skill_trends_returns_history_separately(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
text = r'<script>\"values\":[3,5,8,13]</script>'
|
text = r"<script>\"values\":[3,5,8,13]</script>"
|
||||||
|
|
||||||
def raise_for_status(self) -> None:
|
def raise_for_status(self) -> None:
|
||||||
pass
|
pass
|
||||||
@ -175,11 +263,13 @@ async def test_marketplace_skill_trends_returns_history_separately(
|
|||||||
weekly_installs,
|
weekly_installs,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert await marketplace_skill_trends([
|
assert await marketplace_skill_trends(
|
||||||
"acme/skills/first",
|
[
|
||||||
"other/skills/second",
|
"acme/skills/first",
|
||||||
"invalid",
|
"other/skills/second",
|
||||||
]) == {
|
"invalid",
|
||||||
|
]
|
||||||
|
) == {
|
||||||
"trends": {
|
"trends": {
|
||||||
"acme/skills/first": [2, 4, 3, 8],
|
"acme/skills/first": [2, 4, 3, 8],
|
||||||
"other/skills/second": [3, 5, 8, 13],
|
"other/skills/second": [3, 5, 8, 13],
|
||||||
@ -208,7 +298,7 @@ async def test_search_marketplace_skills_returns_safe_upstream_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(SkillsMarketplaceError) as exc_info:
|
with pytest.raises(SkillsMarketplaceError) as exc_info:
|
||||||
await search_marketplace_skills("react", tmp_path)
|
await search_marketplace_skills("react", tmp_path, provider="skills_sh")
|
||||||
|
|
||||||
assert exc_info.value.status == 502
|
assert exc_info.value.status == 502
|
||||||
assert exc_info.value.message == "skills.sh search is temporarily unavailable"
|
assert exc_info.value.message == "skills.sh search is temporarily unavailable"
|
||||||
@ -277,6 +367,152 @@ async def test_install_marketplace_skill_uses_official_cli_and_workspace(
|
|||||||
assert seen["env"]["DISABLE_TELEMETRY"] == "1"
|
assert seen["env"]["DISABLE_TELEMETRY"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_install_skillhub_skill_checks_fingerprint_and_extracts_safely(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
archive_buffer = io.BytesIO()
|
||||||
|
skill_content = b"---\nname: ima-skills\ndescription: Tencent knowledge skill.\n---\n"
|
||||||
|
with zipfile.ZipFile(archive_buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("SKILL.md", skill_content)
|
||||||
|
archive.writestr("_meta.json", b'{"version":"1.1.8"}')
|
||||||
|
archive_bytes = archive_buffer.getvalue()
|
||||||
|
file_hash = hashlib.sha256(skill_content).hexdigest()
|
||||||
|
content_hash = hashlib.sha256(f"SKILL.md:{file_hash}\n".encode()).hexdigest()
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
status_code: int = 200,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
content: bytes = b"",
|
||||||
|
) -> None:
|
||||||
|
self.payload = payload or {}
|
||||||
|
self.status_code = status_code
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"failed",
|
||||||
|
request=httpx.Request("GET", "https://example.com"),
|
||||||
|
response=httpx.Response(self.status_code),
|
||||||
|
)
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "FakeResponse":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def aiter_bytes(self):
|
||||||
|
yield self.content[:12]
|
||||||
|
yield self.content[12:]
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def __aenter__(self) -> "FakeClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> FakeResponse:
|
||||||
|
if url.endswith("/signature"):
|
||||||
|
return FakeResponse(payload={"signed": True, "content_hash": content_hash})
|
||||||
|
assert url == "https://api.skillhub.cn/api/v1/download"
|
||||||
|
assert params == {"slug": "ima-skills", "version": "1.1.8"}
|
||||||
|
return FakeResponse(
|
||||||
|
status_code=302,
|
||||||
|
headers={
|
||||||
|
"location": (
|
||||||
|
"https://skillhub-1388575217.cos.accelerate.myqcloud.com/"
|
||||||
|
"skills/ima-skills.zip"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def stream(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> FakeResponse:
|
||||||
|
assert method == "GET"
|
||||||
|
assert url.endswith("/skills/ima-skills.zip")
|
||||||
|
assert "application/zip" in headers["Accept"]
|
||||||
|
return FakeResponse(
|
||||||
|
headers={"content-length": str(len(archive_bytes))},
|
||||||
|
content=archive_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
|
||||||
|
lambda **_kwargs: FakeClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await install_marketplace_skill(
|
||||||
|
"",
|
||||||
|
"ima-skills",
|
||||||
|
tmp_path,
|
||||||
|
provider="skillhub",
|
||||||
|
version="1.1.8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"installed": True,
|
||||||
|
"already_installed": False,
|
||||||
|
"name": "ima-skills",
|
||||||
|
"provider": "skillhub",
|
||||||
|
"version": "1.1.8",
|
||||||
|
"verified": True,
|
||||||
|
}
|
||||||
|
assert (tmp_path / "skills" / "ima-skills" / "SKILL.md").read_bytes() == skill_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("url", "valid"),
|
||||||
|
[
|
||||||
|
("https://skillhub.cos.myqcloud.com/skills/example.zip", True),
|
||||||
|
("https://skillhub.cos.myqcloud.com:443/skills/example.zip", True),
|
||||||
|
("http://skillhub.cos.myqcloud.com/skills/example.zip", False),
|
||||||
|
("https://myqcloud.com/skills/example.zip", False),
|
||||||
|
("https://skillhub.cos.myqcloud.com.evil.example/skill.zip", False),
|
||||||
|
("https://user@skillhub.cos.myqcloud.com/skill.zip", False),
|
||||||
|
("https://skillhub.cos.myqcloud.com:not-a-port/skill.zip", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_skillhub_download_url_allows_only_pinned_cloud_hosts(
|
||||||
|
url: str,
|
||||||
|
valid: bool,
|
||||||
|
) -> None:
|
||||||
|
assert _valid_skillhub_download_url(url) is valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_skillhub_archive_rejects_path_traversal() -> None:
|
||||||
|
archive_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(archive_buffer, "w") as archive:
|
||||||
|
archive.writestr("SKILL.md", "---\nname: safe\n---\n")
|
||||||
|
archive.writestr("../outside.sh", "#!/bin/sh\n")
|
||||||
|
archive_buffer.seek(0)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive_buffer) as archive:
|
||||||
|
with pytest.raises(SkillsMarketplaceError, match="unsafe path"):
|
||||||
|
_validated_skillhub_entries(archive)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_install_marketplace_skill_is_idempotent(
|
async def test_install_marketplace_skill_is_idempotent(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@ -28,7 +28,11 @@ import {
|
|||||||
searchMarketplaceSkills,
|
searchMarketplaceSkills,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { notifySkillsChanged } from "@/lib/skill-events";
|
import { notifySkillsChanged } from "@/lib/skill-events";
|
||||||
import type { MarketplaceSkillSummary, SkillSummary } from "@/lib/types";
|
import type {
|
||||||
|
MarketplaceProvider,
|
||||||
|
MarketplaceSkillSummary,
|
||||||
|
SkillSummary,
|
||||||
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
@ -42,7 +46,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [trendingLoading, setTrendingLoading] = useState(true);
|
const [trendingLoading, setTrendingLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [installSupported, setInstallSupported] = useState<boolean | null>(null);
|
const [provider, setProvider] = useState<MarketplaceProvider>("all");
|
||||||
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
|
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
|
||||||
const [installing, setInstalling] = useState("");
|
const [installing, setInstalling] = useState("");
|
||||||
const installedNames = useMemo(
|
const installedNames = useMemo(
|
||||||
@ -53,11 +57,10 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setTrendingLoading(true);
|
setTrendingLoading(true);
|
||||||
fetchTrendingMarketplaceSkills(token)
|
fetchTrendingMarketplaceSkills(token, provider)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTrending(payload.skills);
|
setTrending(payload.skills);
|
||||||
setInstallSupported(payload.install_supported);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setTrending([]);
|
if (!cancelled) setTrending([]);
|
||||||
@ -68,11 +71,13 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [provider, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const skills = query.trim().length < 2 ? trending : results;
|
const skills = query.trim().length < 2 ? trending : results;
|
||||||
const unresolved = skills.filter((skill) => !(skill.id in trends));
|
const unresolved = skills.filter(
|
||||||
|
(skill) => skill.provider === "skills_sh" && !(skill.id in trends),
|
||||||
|
);
|
||||||
if (!unresolved.length) return;
|
if (!unresolved.length) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -101,11 +106,10 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
searchMarketplaceSkills(token, normalized)
|
searchMarketplaceSkills(token, normalized, provider)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setResults(payload.skills);
|
setResults(payload.skills);
|
||||||
setInstallSupported(payload.install_supported);
|
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -127,14 +131,20 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [query, t, token]);
|
}, [provider, query, t, token]);
|
||||||
|
|
||||||
const install = async (skill: MarketplaceSkillSummary) => {
|
const install = async (skill: MarketplaceSkillSummary) => {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setInstalling(skill.skill_id);
|
setInstalling(skill.id);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const payload = await installMarketplaceSkill(token, skill.source, skill.skill_id);
|
const payload = await installMarketplaceSkill(
|
||||||
|
token,
|
||||||
|
skill.provider,
|
||||||
|
skill.source,
|
||||||
|
skill.skill_id,
|
||||||
|
skill.version,
|
||||||
|
);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
setResults((current) =>
|
setResults((current) =>
|
||||||
current.map((item) =>
|
current.map((item) =>
|
||||||
@ -161,33 +171,36 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="relative">
|
<div className="space-y-2">
|
||||||
<Search
|
<div className="relative">
|
||||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
<Search
|
||||||
aria-hidden
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||||
/>
|
aria-hidden
|
||||||
<Input
|
/>
|
||||||
value={query}
|
<Input
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
value={query}
|
||||||
placeholder={t("settings.skills.marketplaceSearchPlaceholder", {
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
defaultValue: "Search skills.sh",
|
placeholder={t("settings.skills.marketplaceSearchPlaceholder", {
|
||||||
})}
|
defaultValue: "Search skills",
|
||||||
aria-label={t("settings.skills.marketplaceSearchLabel", {
|
|
||||||
defaultValue: "Search skills.sh",
|
|
||||||
})}
|
|
||||||
className="h-11 rounded-[14px] bg-settings-surface pl-9"
|
|
||||||
/>
|
|
||||||
{loading ? (
|
|
||||||
<span
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
||||||
role="status"
|
|
||||||
aria-label={t("settings.skills.marketplaceSearching", {
|
|
||||||
defaultValue: "Searching",
|
|
||||||
})}
|
})}
|
||||||
>
|
aria-label={t("settings.skills.marketplaceSearchLabel", {
|
||||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
defaultValue: "Search skills",
|
||||||
</span>
|
})}
|
||||||
) : null}
|
className="h-11 rounded-[14px] bg-settings-surface pl-9"
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<span
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
aria-label={t("settings.skills.marketplaceSearching", {
|
||||||
|
defaultValue: "Searching",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ProviderFilter value={provider} onChange={setProvider} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@ -198,22 +211,22 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
|
|
||||||
{query.trim().length < 2 ? (
|
{query.trim().length < 2 ? (
|
||||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||||
<div className="flex flex-col items-start gap-2 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
<div className="flex flex-col items-start gap-2 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[14px] font-semibold">
|
<h2 className="text-[14px] font-semibold">
|
||||||
{t("settings.skills.marketplaceTrendingTitle", {
|
{t("settings.skills.marketplaceTrendingTitle", {
|
||||||
defaultValue: "Trending today",
|
defaultValue: "Trending by marketplace",
|
||||||
})}
|
})}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||||
{t("settings.skills.marketplaceTrendingDescription", {
|
{t("settings.skills.marketplaceTrendingDescription", {
|
||||||
defaultValue:
|
defaultValue: "Each marketplace keeps its own ranking and install metrics.",
|
||||||
"Most installed across sources in 24h · curves show the 8-week trend",
|
})}
|
||||||
})}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
{provider !== "all" ? (
|
||||||
<a
|
<a
|
||||||
href="https://skills.sh/trending"
|
href={providerUrl(provider)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
className="inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
@ -221,26 +234,26 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
{t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })}
|
{t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })}
|
||||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||||
</a>
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{trendingLoading ? (
|
||||||
|
<TrendingSkeleton />
|
||||||
|
) : trending.length ? (
|
||||||
|
<MarketplaceSkillGroups
|
||||||
|
skills={trending}
|
||||||
|
installedNames={installedNames}
|
||||||
|
installing={installing}
|
||||||
|
trends={trends}
|
||||||
|
grouped={provider === "all"}
|
||||||
|
onSelect={setSelected}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="px-5 py-10 text-center text-[13px] text-muted-foreground">
|
||||||
|
{t("settings.skills.marketplaceTrendingUnavailable", {
|
||||||
|
defaultValue: "Trending skills are temporarily unavailable.",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{trendingLoading ? (
|
)}
|
||||||
<TrendingSkeleton />
|
|
||||||
) : trending.length ? (
|
|
||||||
<MarketplaceSkillList
|
|
||||||
skills={trending}
|
|
||||||
installedNames={installedNames}
|
|
||||||
installing={installing}
|
|
||||||
installSupported={installSupported}
|
|
||||||
metric="24h"
|
|
||||||
trends={trends}
|
|
||||||
onSelect={setSelected}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="px-5 py-10 text-center text-[13px] text-muted-foreground">
|
|
||||||
{t("settings.skills.marketplaceTrendingUnavailable", {
|
|
||||||
defaultValue: "Trending skills are temporarily unavailable.",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
) : !loading && results.length === 0 && !error ? (
|
) : !loading && results.length === 0 && !error ? (
|
||||||
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
|
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
|
||||||
@ -251,13 +264,12 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||||
<MarketplaceSkillList
|
<MarketplaceSkillGroups
|
||||||
skills={results}
|
skills={results}
|
||||||
installedNames={installedNames}
|
installedNames={installedNames}
|
||||||
installing={installing}
|
installing={installing}
|
||||||
installSupported={installSupported}
|
|
||||||
metric="total"
|
|
||||||
trends={trends}
|
trends={trends}
|
||||||
|
grouped={provider === "all"}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -284,13 +296,16 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
<span className="block">
|
<span className="block">
|
||||||
{t("settings.skills.marketplaceConfirmDescription", {
|
{t("settings.skills.marketplaceConfirmDescription", {
|
||||||
source: selected?.source ?? "",
|
source: selected?.source ?? "",
|
||||||
|
provider: selected ? providerLabel(selected.provider) : "",
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"This third-party skill comes from {{source}} and may include instructions or executable scripts.",
|
"This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<code className="block rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
|
<span className="flex flex-wrap items-center gap-2 rounded-md bg-muted px-2 py-1.5 text-[12px] text-foreground">
|
||||||
{selected?.source}
|
{selected ? <ProviderMark provider={selected.provider} /> : null}
|
||||||
</code>
|
<code>{selected?.source}</code>
|
||||||
|
{selected?.version ? <span>v{selected.version}</span> : null}
|
||||||
|
</span>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@ -313,20 +328,118 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderFilter({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: MarketplaceProvider;
|
||||||
|
onChange: (provider: MarketplaceProvider) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const providers: MarketplaceProvider[] = ["all", "skills_sh", "skillhub"];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex w-fit items-center gap-0.5 rounded-full bg-settings-surface p-1"
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t("settings.skills.marketplaceProviderFilter", {
|
||||||
|
defaultValue: "Skill source",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === provider}
|
||||||
|
onClick={() => onChange(provider)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
||||||
|
value === provider && "bg-background text-foreground shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{provider !== "all" ? <ProviderDot provider={provider} /> : null}
|
||||||
|
{provider === "all"
|
||||||
|
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
|
||||||
|
: providerLabel(provider)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarketplaceSkillGroups({
|
||||||
|
skills,
|
||||||
|
installedNames,
|
||||||
|
installing,
|
||||||
|
trends,
|
||||||
|
grouped,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
skills: MarketplaceSkillSummary[];
|
||||||
|
installedNames: Set<string>;
|
||||||
|
installing: string;
|
||||||
|
trends: Record<string, number[]>;
|
||||||
|
grouped: boolean;
|
||||||
|
onSelect: (skill: MarketplaceSkillSummary) => void;
|
||||||
|
}) {
|
||||||
|
const providers: Array<Exclude<MarketplaceProvider, "all">> = [
|
||||||
|
"skills_sh",
|
||||||
|
"skillhub",
|
||||||
|
];
|
||||||
|
if (!grouped) {
|
||||||
|
return (
|
||||||
|
<MarketplaceSkillList
|
||||||
|
skills={skills}
|
||||||
|
installedNames={installedNames}
|
||||||
|
installing={installing}
|
||||||
|
trends={trends}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{providers.map((provider) => {
|
||||||
|
const providerSkills = skills.filter((skill) => skill.provider === provider);
|
||||||
|
if (!providerSkills.length) return null;
|
||||||
|
return (
|
||||||
|
<section key={provider} className="border-t border-border/45 first:border-t-0">
|
||||||
|
<div className="flex items-center justify-between px-5 pb-1 pt-3.5">
|
||||||
|
<ProviderMark provider={provider} />
|
||||||
|
<a
|
||||||
|
href={providerUrl(provider)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
aria-label={`Open ${providerLabel(provider)}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<MarketplaceSkillList
|
||||||
|
skills={providerSkills}
|
||||||
|
installedNames={installedNames}
|
||||||
|
installing={installing}
|
||||||
|
trends={trends}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MarketplaceSkillList({
|
function MarketplaceSkillList({
|
||||||
skills,
|
skills,
|
||||||
installedNames,
|
installedNames,
|
||||||
installing,
|
installing,
|
||||||
installSupported,
|
|
||||||
metric,
|
|
||||||
trends,
|
trends,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
skills: MarketplaceSkillSummary[];
|
skills: MarketplaceSkillSummary[];
|
||||||
installedNames: Set<string>;
|
installedNames: Set<string>;
|
||||||
installing: string;
|
installing: string;
|
||||||
installSupported: boolean | null;
|
|
||||||
metric: "total" | "24h";
|
|
||||||
trends: Record<string, number[]>;
|
trends: Record<string, number[]>;
|
||||||
onSelect: (skill: MarketplaceSkillSummary) => void;
|
onSelect: (skill: MarketplaceSkillSummary) => void;
|
||||||
}) {
|
}) {
|
||||||
@ -337,10 +450,8 @@ function MarketplaceSkillList({
|
|||||||
key={skill.id}
|
key={skill.id}
|
||||||
skill={skill}
|
skill={skill}
|
||||||
installed={skill.installed || installedNames.has(skill.skill_id)}
|
installed={skill.installed || installedNames.has(skill.skill_id)}
|
||||||
isInstalling={installing === skill.skill_id}
|
isInstalling={installing === skill.id}
|
||||||
installBusy={Boolean(installing)}
|
installBusy={Boolean(installing)}
|
||||||
installSupported={installSupported}
|
|
||||||
metric={metric}
|
|
||||||
trend={trends[skill.id]}
|
trend={trends[skill.id]}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
@ -354,8 +465,6 @@ function MarketplaceSkillRow({
|
|||||||
installed,
|
installed,
|
||||||
isInstalling,
|
isInstalling,
|
||||||
installBusy,
|
installBusy,
|
||||||
installSupported,
|
|
||||||
metric,
|
|
||||||
trend,
|
trend,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
@ -363,8 +472,6 @@ function MarketplaceSkillRow({
|
|||||||
installed: boolean;
|
installed: boolean;
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
installBusy: boolean;
|
installBusy: boolean;
|
||||||
installSupported: boolean | null;
|
|
||||||
metric: "total" | "24h";
|
|
||||||
trend?: number[];
|
trend?: number[];
|
||||||
onSelect: (skill: MarketplaceSkillSummary) => void;
|
onSelect: (skill: MarketplaceSkillSummary) => void;
|
||||||
}) {
|
}) {
|
||||||
@ -388,17 +495,20 @@ function MarketplaceSkillRow({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
aria-label={t("settings.skills.marketplaceOpen", {
|
aria-label={t("settings.skills.marketplaceOpen", {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
defaultValue: "Open {{name}} on skills.sh",
|
provider: providerLabel(skill.provider),
|
||||||
|
defaultValue: "Open {{name}} on {{provider}}",
|
||||||
})}
|
})}
|
||||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 truncate text-[12px] text-muted-foreground">
|
<div className="mt-1 flex min-w-0 items-center gap-1.5 truncate text-[12px] text-muted-foreground">
|
||||||
|
<ProviderMark provider={skill.provider} compact />
|
||||||
{skill.source}
|
{skill.source}
|
||||||
<span className="mx-1.5">·</span>
|
{skill.version ? <span>· v{skill.version}</span> : null}
|
||||||
{metric === "24h"
|
<span>·</span>
|
||||||
|
{skill.metric === "installs_24h"
|
||||||
? t("settings.skills.marketplaceInstalls24h", {
|
? t("settings.skills.marketplaceInstalls24h", {
|
||||||
count: skill.installs,
|
count: skill.installs,
|
||||||
formattedCount: skill.installs.toLocaleString(),
|
formattedCount: skill.installs.toLocaleString(),
|
||||||
@ -409,21 +519,28 @@ function MarketplaceSkillRow({
|
|||||||
formattedCount: skill.installs.toLocaleString(),
|
formattedCount: skill.installs.toLocaleString(),
|
||||||
defaultValue: "{{formattedCount}} installs",
|
defaultValue: "{{formattedCount}} installs",
|
||||||
})}
|
})}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TrendSparkline values={trend} />
|
{skill.provider === "skills_sh" ? <TrendSparkline values={trend} /> : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={`${t(
|
||||||
|
isInstalling
|
||||||
|
? "settings.skills.marketplaceInstalling"
|
||||||
|
: installed
|
||||||
|
? "settings.skills.marketplaceInstalled"
|
||||||
|
: "settings.skills.marketplaceInstall",
|
||||||
|
)} ${skill.name}`}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={installed ? "secondary" : "default"}
|
variant={installed ? "secondary" : "default"}
|
||||||
disabled={installed || installBusy || installSupported === false}
|
disabled={installed || installBusy || !skill.install_supported}
|
||||||
onClick={() => onSelect(skill)}
|
onClick={() => onSelect(skill)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[82px] rounded-full px-2.5 sm:min-w-[92px] sm:px-3",
|
"min-w-[82px] rounded-full px-2.5 sm:min-w-[92px] sm:px-3",
|
||||||
installed && "text-emerald-700",
|
installed && "text-emerald-700",
|
||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
installSupported === false
|
!skill.install_supported
|
||||||
? t("settings.skills.marketplaceNpxRequired", {
|
? t("settings.skills.marketplaceNpxRequired", {
|
||||||
defaultValue: "Node.js with npx is required",
|
defaultValue: "Node.js with npx is required",
|
||||||
})
|
})
|
||||||
@ -453,6 +570,50 @@ function MarketplaceSkillRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderMark({
|
||||||
|
provider,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
provider: Exclude<MarketplaceProvider, "all">;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center gap-1.5 font-medium",
|
||||||
|
compact ? "text-[11px] text-muted-foreground" : "text-[12px] text-foreground/75",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderDot provider={provider} />
|
||||||
|
{providerLabel(provider)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderDot({
|
||||||
|
provider,
|
||||||
|
}: {
|
||||||
|
provider: Exclude<MarketplaceProvider, "all">;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||||
|
provider === "skillhub" ? "bg-[#006EFF]" : "bg-foreground/55",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(provider: Exclude<MarketplaceProvider, "all">): string {
|
||||||
|
return provider === "skillhub" ? "SkillHub" : "skills.sh";
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerUrl(provider: Exclude<MarketplaceProvider, "all">): string {
|
||||||
|
return provider === "skillhub" ? "https://skillhub.cn" : "https://skills.sh/trending";
|
||||||
|
}
|
||||||
|
|
||||||
function TrendSparkline({ values }: { values?: number[] }) {
|
function TrendSparkline({ values }: { values?: number[] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@ -791,6 +791,29 @@
|
|||||||
"views": "Skills views",
|
"views": "Skills views",
|
||||||
"installedTab": "Installed",
|
"installedTab": "Installed",
|
||||||
"discoverTab": "Discover",
|
"discoverTab": "Discover",
|
||||||
|
"marketplaceSearchFailed": "Could not search skill marketplaces.",
|
||||||
|
"marketplaceInstallFailed": "Could not install this skill.",
|
||||||
|
"marketplaceSearchPlaceholder": "Search skills",
|
||||||
|
"marketplaceSearchLabel": "Search skills",
|
||||||
|
"marketplaceSearching": "Searching",
|
||||||
|
"marketplaceProviderFilter": "Skill source",
|
||||||
|
"marketplaceProviderAll": "All",
|
||||||
|
"marketplaceTrendingTitle": "Trending by marketplace",
|
||||||
|
"marketplaceTrendingDescription": "Each marketplace keeps its own ranking and install metrics.",
|
||||||
|
"marketplaceViewAll": "View all",
|
||||||
|
"marketplaceTrendingUnavailable": "Trending skills are temporarily unavailable.",
|
||||||
|
"marketplaceEmpty": "No skills found for “{{query}}”.",
|
||||||
|
"marketplaceConfirmTitle": "Install {{name}}?",
|
||||||
|
"marketplaceConfirmDescription": "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
|
||||||
|
"marketplaceConfirmInstall": "Install skill",
|
||||||
|
"marketplaceOpen": "Open {{name}} on {{provider}}",
|
||||||
|
"marketplaceInstalls24h": "{{formattedCount}} installs / 24h",
|
||||||
|
"marketplaceInstalls": "{{formattedCount}} installs",
|
||||||
|
"marketplaceNpxRequired": "Node.js with npx is required",
|
||||||
|
"marketplaceInstalling": "Installing",
|
||||||
|
"marketplaceInstalled": "Installed",
|
||||||
|
"marketplaceInstall": "Install",
|
||||||
|
"marketplaceNoTrend": "No trend yet",
|
||||||
"featured": "Agent skills",
|
"featured": "Agent skills",
|
||||||
"empty": "No skills are available.",
|
"empty": "No skills are available.",
|
||||||
"sourceWorkspace": "Custom",
|
"sourceWorkspace": "Custom",
|
||||||
|
|||||||
@ -778,6 +778,29 @@
|
|||||||
"views": "Vistas de habilidades",
|
"views": "Vistas de habilidades",
|
||||||
"installedTab": "Instaladas",
|
"installedTab": "Instaladas",
|
||||||
"discoverTab": "Descubrir",
|
"discoverTab": "Descubrir",
|
||||||
|
"marketplaceSearchFailed": "No se pudieron buscar los mercados de skills.",
|
||||||
|
"marketplaceInstallFailed": "No se pudo instalar este skill.",
|
||||||
|
"marketplaceSearchPlaceholder": "Buscar skills",
|
||||||
|
"marketplaceSearchLabel": "Buscar skills",
|
||||||
|
"marketplaceSearching": "Buscando",
|
||||||
|
"marketplaceProviderFilter": "Origen del skill",
|
||||||
|
"marketplaceProviderAll": "Todos",
|
||||||
|
"marketplaceTrendingTitle": "Tendencias por mercado",
|
||||||
|
"marketplaceTrendingDescription": "Cada mercado conserva su propio ranking y métricas de instalación.",
|
||||||
|
"marketplaceViewAll": "Ver todos",
|
||||||
|
"marketplaceTrendingUnavailable": "Los skills populares no están disponibles temporalmente.",
|
||||||
|
"marketplaceEmpty": "No se encontraron skills para “{{query}}”.",
|
||||||
|
"marketplaceConfirmTitle": "¿Instalar {{name}}?",
|
||||||
|
"marketplaceConfirmDescription": "Este skill de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.",
|
||||||
|
"marketplaceConfirmInstall": "Instalar skill",
|
||||||
|
"marketplaceOpen": "Abrir {{name}} en {{provider}}",
|
||||||
|
"marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h",
|
||||||
|
"marketplaceInstalls": "{{formattedCount}} instalaciones",
|
||||||
|
"marketplaceNpxRequired": "Se requiere Node.js con npx",
|
||||||
|
"marketplaceInstalling": "Instalando",
|
||||||
|
"marketplaceInstalled": "Instalado",
|
||||||
|
"marketplaceInstall": "Instalar",
|
||||||
|
"marketplaceNoTrend": "Sin tendencia todavía",
|
||||||
"featured": "Habilidades del agente",
|
"featured": "Habilidades del agente",
|
||||||
"empty": "No hay habilidades disponibles.",
|
"empty": "No hay habilidades disponibles.",
|
||||||
"sourceWorkspace": "Personalizada",
|
"sourceWorkspace": "Personalizada",
|
||||||
|
|||||||
@ -777,6 +777,29 @@
|
|||||||
"views": "Vues des compétences",
|
"views": "Vues des compétences",
|
||||||
"installedTab": "Installées",
|
"installedTab": "Installées",
|
||||||
"discoverTab": "Découvrir",
|
"discoverTab": "Découvrir",
|
||||||
|
"marketplaceSearchFailed": "Impossible de rechercher dans les catalogues de compétences.",
|
||||||
|
"marketplaceInstallFailed": "Impossible d’installer cette compétence.",
|
||||||
|
"marketplaceSearchPlaceholder": "Rechercher des compétences",
|
||||||
|
"marketplaceSearchLabel": "Rechercher des compétences",
|
||||||
|
"marketplaceSearching": "Recherche en cours",
|
||||||
|
"marketplaceProviderFilter": "Source des compétences",
|
||||||
|
"marketplaceProviderAll": "Toutes",
|
||||||
|
"marketplaceTrendingTitle": "Tendances par catalogue",
|
||||||
|
"marketplaceTrendingDescription": "Chaque catalogue conserve son propre classement et ses propres statistiques d’installation.",
|
||||||
|
"marketplaceViewAll": "Tout afficher",
|
||||||
|
"marketplaceTrendingUnavailable": "Les compétences populaires sont temporairement indisponibles.",
|
||||||
|
"marketplaceEmpty": "Aucune compétence trouvée pour « {{query}} ».",
|
||||||
|
"marketplaceConfirmTitle": "Installer {{name}} ?",
|
||||||
|
"marketplaceConfirmDescription": "Cette compétence tierce provient de {{provider}} ({{source}}) et peut contenir des instructions ou des scripts exécutables.",
|
||||||
|
"marketplaceConfirmInstall": "Installer la compétence",
|
||||||
|
"marketplaceOpen": "Ouvrir {{name}} sur {{provider}}",
|
||||||
|
"marketplaceInstalls24h": "{{formattedCount}} installations / 24 h",
|
||||||
|
"marketplaceInstalls": "{{formattedCount}} installations",
|
||||||
|
"marketplaceNpxRequired": "Node.js avec npx est requis",
|
||||||
|
"marketplaceInstalling": "Installation",
|
||||||
|
"marketplaceInstalled": "Installée",
|
||||||
|
"marketplaceInstall": "Installer",
|
||||||
|
"marketplaceNoTrend": "Pas encore de tendance",
|
||||||
"featured": "Compétences agent",
|
"featured": "Compétences agent",
|
||||||
"empty": "Aucune compétence disponible.",
|
"empty": "Aucune compétence disponible.",
|
||||||
"sourceWorkspace": "Personnalisée",
|
"sourceWorkspace": "Personnalisée",
|
||||||
|
|||||||
@ -777,6 +777,29 @@
|
|||||||
"views": "Tampilan skill",
|
"views": "Tampilan skill",
|
||||||
"installedTab": "Terpasang",
|
"installedTab": "Terpasang",
|
||||||
"discoverTab": "Temukan",
|
"discoverTab": "Temukan",
|
||||||
|
"marketplaceSearchFailed": "Tidak dapat mencari marketplace skill.",
|
||||||
|
"marketplaceInstallFailed": "Tidak dapat memasang skill ini.",
|
||||||
|
"marketplaceSearchPlaceholder": "Cari skill",
|
||||||
|
"marketplaceSearchLabel": "Cari skill",
|
||||||
|
"marketplaceSearching": "Mencari",
|
||||||
|
"marketplaceProviderFilter": "Sumber skill",
|
||||||
|
"marketplaceProviderAll": "Semua",
|
||||||
|
"marketplaceTrendingTitle": "Tren per marketplace",
|
||||||
|
"marketplaceTrendingDescription": "Setiap marketplace mempertahankan peringkat dan metrik pemasangannya sendiri.",
|
||||||
|
"marketplaceViewAll": "Lihat semua",
|
||||||
|
"marketplaceTrendingUnavailable": "Skill populer sementara tidak tersedia.",
|
||||||
|
"marketplaceEmpty": "Tidak ada skill yang ditemukan untuk “{{query}}”.",
|
||||||
|
"marketplaceConfirmTitle": "Pasang {{name}}?",
|
||||||
|
"marketplaceConfirmDescription": "Skill pihak ketiga ini berasal dari {{provider}} ({{source}}) dan mungkin berisi instruksi atau skrip yang dapat dijalankan.",
|
||||||
|
"marketplaceConfirmInstall": "Pasang skill",
|
||||||
|
"marketplaceOpen": "Buka {{name}} di {{provider}}",
|
||||||
|
"marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam",
|
||||||
|
"marketplaceInstalls": "{{formattedCount}} pemasangan",
|
||||||
|
"marketplaceNpxRequired": "Node.js dengan npx diperlukan",
|
||||||
|
"marketplaceInstalling": "Memasang",
|
||||||
|
"marketplaceInstalled": "Terpasang",
|
||||||
|
"marketplaceInstall": "Pasang",
|
||||||
|
"marketplaceNoTrend": "Belum ada tren",
|
||||||
"featured": "Skill agent",
|
"featured": "Skill agent",
|
||||||
"empty": "Tidak ada skill yang tersedia.",
|
"empty": "Tidak ada skill yang tersedia.",
|
||||||
"sourceWorkspace": "Kustom",
|
"sourceWorkspace": "Kustom",
|
||||||
|
|||||||
@ -777,6 +777,29 @@
|
|||||||
"views": "スキル表示",
|
"views": "スキル表示",
|
||||||
"installedTab": "インストール済み",
|
"installedTab": "インストール済み",
|
||||||
"discoverTab": "見つける",
|
"discoverTab": "見つける",
|
||||||
|
"marketplaceSearchFailed": "スキルマーケットを検索できませんでした。",
|
||||||
|
"marketplaceInstallFailed": "このスキルをインストールできませんでした。",
|
||||||
|
"marketplaceSearchPlaceholder": "スキルを検索",
|
||||||
|
"marketplaceSearchLabel": "スキルを検索",
|
||||||
|
"marketplaceSearching": "検索中",
|
||||||
|
"marketplaceProviderFilter": "スキルの提供元",
|
||||||
|
"marketplaceProviderAll": "すべて",
|
||||||
|
"marketplaceTrendingTitle": "マーケット別トレンド",
|
||||||
|
"marketplaceTrendingDescription": "各マーケットのランキングとインストール指標を個別に表示します。",
|
||||||
|
"marketplaceViewAll": "すべて表示",
|
||||||
|
"marketplaceTrendingUnavailable": "トレンドスキルを一時的に取得できません。",
|
||||||
|
"marketplaceEmpty": "「{{query}}」に一致するスキルはありません。",
|
||||||
|
"marketplaceConfirmTitle": "{{name}} をインストールしますか?",
|
||||||
|
"marketplaceConfirmDescription": "このサードパーティ製スキルは {{provider}}({{source}})から提供され、指示や実行可能なスクリプトを含む場合があります。",
|
||||||
|
"marketplaceConfirmInstall": "スキルをインストール",
|
||||||
|
"marketplaceOpen": "{{provider}} で {{name}} を開く",
|
||||||
|
"marketplaceInstalls24h": "24時間で {{formattedCount}} 回インストール",
|
||||||
|
"marketplaceInstalls": "{{formattedCount}} 回インストール",
|
||||||
|
"marketplaceNpxRequired": "npx を含む Node.js が必要です",
|
||||||
|
"marketplaceInstalling": "インストール中",
|
||||||
|
"marketplaceInstalled": "インストール済み",
|
||||||
|
"marketplaceInstall": "インストール",
|
||||||
|
"marketplaceNoTrend": "トレンドなし",
|
||||||
"featured": "エージェントスキル",
|
"featured": "エージェントスキル",
|
||||||
"empty": "利用可能なスキルはありません。",
|
"empty": "利用可能なスキルはありません。",
|
||||||
"sourceWorkspace": "カスタム",
|
"sourceWorkspace": "カスタム",
|
||||||
|
|||||||
@ -777,6 +777,29 @@
|
|||||||
"views": "스킬 보기",
|
"views": "스킬 보기",
|
||||||
"installedTab": "설치됨",
|
"installedTab": "설치됨",
|
||||||
"discoverTab": "탐색",
|
"discoverTab": "탐색",
|
||||||
|
"marketplaceSearchFailed": "스킬 마켓을 검색할 수 없습니다.",
|
||||||
|
"marketplaceInstallFailed": "이 스킬을 설치할 수 없습니다.",
|
||||||
|
"marketplaceSearchPlaceholder": "스킬 검색",
|
||||||
|
"marketplaceSearchLabel": "스킬 검색",
|
||||||
|
"marketplaceSearching": "검색 중",
|
||||||
|
"marketplaceProviderFilter": "스킬 출처",
|
||||||
|
"marketplaceProviderAll": "전체",
|
||||||
|
"marketplaceTrendingTitle": "마켓별 인기 스킬",
|
||||||
|
"marketplaceTrendingDescription": "각 마켓의 순위와 설치 지표를 별도로 표시합니다.",
|
||||||
|
"marketplaceViewAll": "모두 보기",
|
||||||
|
"marketplaceTrendingUnavailable": "인기 스킬을 일시적으로 불러올 수 없습니다.",
|
||||||
|
"marketplaceEmpty": "“{{query}}”에 해당하는 스킬이 없습니다.",
|
||||||
|
"marketplaceConfirmTitle": "{{name}}을(를) 설치할까요?",
|
||||||
|
"marketplaceConfirmDescription": "이 타사 스킬은 {{provider}}({{source}})에서 제공되며 지침이나 실행 가능한 스크립트를 포함할 수 있습니다.",
|
||||||
|
"marketplaceConfirmInstall": "스킬 설치",
|
||||||
|
"marketplaceOpen": "{{provider}}에서 {{name}} 열기",
|
||||||
|
"marketplaceInstalls24h": "24시간 동안 {{formattedCount}}회 설치",
|
||||||
|
"marketplaceInstalls": "{{formattedCount}}회 설치",
|
||||||
|
"marketplaceNpxRequired": "npx가 포함된 Node.js가 필요합니다",
|
||||||
|
"marketplaceInstalling": "설치 중",
|
||||||
|
"marketplaceInstalled": "설치됨",
|
||||||
|
"marketplaceInstall": "설치",
|
||||||
|
"marketplaceNoTrend": "추세 없음",
|
||||||
"featured": "에이전트 스킬",
|
"featured": "에이전트 스킬",
|
||||||
"empty": "사용 가능한 스킬이 없습니다.",
|
"empty": "사용 가능한 스킬이 없습니다.",
|
||||||
"sourceWorkspace": "사용자 지정",
|
"sourceWorkspace": "사용자 지정",
|
||||||
|
|||||||
@ -791,6 +791,29 @@
|
|||||||
"views": "Visualizações de skills",
|
"views": "Visualizações de skills",
|
||||||
"installedTab": "Instaladas",
|
"installedTab": "Instaladas",
|
||||||
"discoverTab": "Descobrir",
|
"discoverTab": "Descobrir",
|
||||||
|
"marketplaceSearchFailed": "Não foi possível pesquisar nos mercados de skills.",
|
||||||
|
"marketplaceInstallFailed": "Não foi possível instalar esta skill.",
|
||||||
|
"marketplaceSearchPlaceholder": "Pesquisar skills",
|
||||||
|
"marketplaceSearchLabel": "Pesquisar skills",
|
||||||
|
"marketplaceSearching": "Pesquisando",
|
||||||
|
"marketplaceProviderFilter": "Origem da skill",
|
||||||
|
"marketplaceProviderAll": "Todas",
|
||||||
|
"marketplaceTrendingTitle": "Tendências por mercado",
|
||||||
|
"marketplaceTrendingDescription": "Cada mercado mantém seu próprio ranking e métricas de instalação.",
|
||||||
|
"marketplaceViewAll": "Ver todas",
|
||||||
|
"marketplaceTrendingUnavailable": "As skills em alta estão temporariamente indisponíveis.",
|
||||||
|
"marketplaceEmpty": "Nenhuma skill encontrada para “{{query}}”.",
|
||||||
|
"marketplaceConfirmTitle": "Instalar {{name}}?",
|
||||||
|
"marketplaceConfirmDescription": "Esta skill de terceiros vem de {{provider}} ({{source}}) e pode incluir instruções ou scripts executáveis.",
|
||||||
|
"marketplaceConfirmInstall": "Instalar skill",
|
||||||
|
"marketplaceOpen": "Abrir {{name}} no {{provider}}",
|
||||||
|
"marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h",
|
||||||
|
"marketplaceInstalls": "{{formattedCount}} instalações",
|
||||||
|
"marketplaceNpxRequired": "Node.js com npx é necessário",
|
||||||
|
"marketplaceInstalling": "Instalando",
|
||||||
|
"marketplaceInstalled": "Instalada",
|
||||||
|
"marketplaceInstall": "Instalar",
|
||||||
|
"marketplaceNoTrend": "Ainda sem tendência",
|
||||||
"featured": "Skills do agente",
|
"featured": "Skills do agente",
|
||||||
"empty": "Nenhuma skill disponível.",
|
"empty": "Nenhuma skill disponível.",
|
||||||
"sourceWorkspace": "Personalizada",
|
"sourceWorkspace": "Personalizada",
|
||||||
|
|||||||
@ -777,6 +777,29 @@
|
|||||||
"views": "Chế độ xem kỹ năng",
|
"views": "Chế độ xem kỹ năng",
|
||||||
"installedTab": "Đã cài đặt",
|
"installedTab": "Đã cài đặt",
|
||||||
"discoverTab": "Khám phá",
|
"discoverTab": "Khám phá",
|
||||||
|
"marketplaceSearchFailed": "Không thể tìm kiếm các kho kỹ năng.",
|
||||||
|
"marketplaceInstallFailed": "Không thể cài đặt kỹ năng này.",
|
||||||
|
"marketplaceSearchPlaceholder": "Tìm kiếm kỹ năng",
|
||||||
|
"marketplaceSearchLabel": "Tìm kiếm kỹ năng",
|
||||||
|
"marketplaceSearching": "Đang tìm kiếm",
|
||||||
|
"marketplaceProviderFilter": "Nguồn kỹ năng",
|
||||||
|
"marketplaceProviderAll": "Tất cả",
|
||||||
|
"marketplaceTrendingTitle": "Xu hướng theo kho",
|
||||||
|
"marketplaceTrendingDescription": "Mỗi kho giữ bảng xếp hạng và số liệu cài đặt riêng.",
|
||||||
|
"marketplaceViewAll": "Xem tất cả",
|
||||||
|
"marketplaceTrendingUnavailable": "Các kỹ năng thịnh hành tạm thời không khả dụng.",
|
||||||
|
"marketplaceEmpty": "Không tìm thấy kỹ năng cho “{{query}}”.",
|
||||||
|
"marketplaceConfirmTitle": "Cài đặt {{name}}?",
|
||||||
|
"marketplaceConfirmDescription": "Kỹ năng bên thứ ba này đến từ {{provider}} ({{source}}) và có thể chứa hướng dẫn hoặc tập lệnh thực thi.",
|
||||||
|
"marketplaceConfirmInstall": "Cài đặt kỹ năng",
|
||||||
|
"marketplaceOpen": "Mở {{name}} trên {{provider}}",
|
||||||
|
"marketplaceInstalls24h": "{{formattedCount}} lượt cài đặt / 24 giờ",
|
||||||
|
"marketplaceInstalls": "{{formattedCount}} lượt cài đặt",
|
||||||
|
"marketplaceNpxRequired": "Cần Node.js có npx",
|
||||||
|
"marketplaceInstalling": "Đang cài đặt",
|
||||||
|
"marketplaceInstalled": "Đã cài đặt",
|
||||||
|
"marketplaceInstall": "Cài đặt",
|
||||||
|
"marketplaceNoTrend": "Chưa có xu hướng",
|
||||||
"featured": "Kỹ năng agent",
|
"featured": "Kỹ năng agent",
|
||||||
"empty": "Không có kỹ năng nào khả dụng.",
|
"empty": "Không có kỹ năng nào khả dụng.",
|
||||||
"sourceWorkspace": "Tùy chỉnh",
|
"sourceWorkspace": "Tùy chỉnh",
|
||||||
|
|||||||
@ -791,6 +791,29 @@
|
|||||||
"views": "技能视图",
|
"views": "技能视图",
|
||||||
"installedTab": "已安装",
|
"installedTab": "已安装",
|
||||||
"discoverTab": "发现",
|
"discoverTab": "发现",
|
||||||
|
"marketplaceSearchFailed": "暂时无法搜索技能市场。",
|
||||||
|
"marketplaceInstallFailed": "无法安装此技能。",
|
||||||
|
"marketplaceSearchPlaceholder": "搜索技能",
|
||||||
|
"marketplaceSearchLabel": "搜索技能",
|
||||||
|
"marketplaceSearching": "正在搜索",
|
||||||
|
"marketplaceProviderFilter": "技能来源",
|
||||||
|
"marketplaceProviderAll": "全部",
|
||||||
|
"marketplaceTrendingTitle": "各市场热门技能",
|
||||||
|
"marketplaceTrendingDescription": "不同市场分别保留自己的榜单和安装指标。",
|
||||||
|
"marketplaceViewAll": "查看全部",
|
||||||
|
"marketplaceTrendingUnavailable": "暂时无法获取热门技能。",
|
||||||
|
"marketplaceEmpty": "没有找到与“{{query}}”相关的技能。",
|
||||||
|
"marketplaceConfirmTitle": "安装 {{name}}?",
|
||||||
|
"marketplaceConfirmDescription": "此第三方技能来自 {{provider}}({{source}}),其中可能包含操作指令或可执行脚本。",
|
||||||
|
"marketplaceConfirmInstall": "安装技能",
|
||||||
|
"marketplaceOpen": "在 {{provider}} 中打开 {{name}}",
|
||||||
|
"marketplaceInstalls24h": "24 小时内安装 {{formattedCount}} 次",
|
||||||
|
"marketplaceInstalls": "安装 {{formattedCount}} 次",
|
||||||
|
"marketplaceNpxRequired": "需要安装带有 npx 的 Node.js",
|
||||||
|
"marketplaceInstalling": "正在安装",
|
||||||
|
"marketplaceInstalled": "已安装",
|
||||||
|
"marketplaceInstall": "安装",
|
||||||
|
"marketplaceNoTrend": "暂无趋势",
|
||||||
"featured": "Agent 技能",
|
"featured": "Agent 技能",
|
||||||
"empty": "暂无可用技能。",
|
"empty": "暂无可用技能。",
|
||||||
"sourceWorkspace": "自定义",
|
"sourceWorkspace": "自定义",
|
||||||
|
|||||||
@ -777,6 +777,29 @@
|
|||||||
"views": "技能檢視",
|
"views": "技能檢視",
|
||||||
"installedTab": "已安裝",
|
"installedTab": "已安裝",
|
||||||
"discoverTab": "探索",
|
"discoverTab": "探索",
|
||||||
|
"marketplaceSearchFailed": "暫時無法搜尋技能市集。",
|
||||||
|
"marketplaceInstallFailed": "無法安裝此技能。",
|
||||||
|
"marketplaceSearchPlaceholder": "搜尋技能",
|
||||||
|
"marketplaceSearchLabel": "搜尋技能",
|
||||||
|
"marketplaceSearching": "正在搜尋",
|
||||||
|
"marketplaceProviderFilter": "技能來源",
|
||||||
|
"marketplaceProviderAll": "全部",
|
||||||
|
"marketplaceTrendingTitle": "各市集熱門技能",
|
||||||
|
"marketplaceTrendingDescription": "不同市集分別保留自己的排行與安裝指標。",
|
||||||
|
"marketplaceViewAll": "查看全部",
|
||||||
|
"marketplaceTrendingUnavailable": "暫時無法取得熱門技能。",
|
||||||
|
"marketplaceEmpty": "找不到與「{{query}}」相關的技能。",
|
||||||
|
"marketplaceConfirmTitle": "安裝 {{name}}?",
|
||||||
|
"marketplaceConfirmDescription": "此第三方技能來自 {{provider}}({{source}}),其中可能包含操作指示或可執行腳本。",
|
||||||
|
"marketplaceConfirmInstall": "安裝技能",
|
||||||
|
"marketplaceOpen": "在 {{provider}} 開啟 {{name}}",
|
||||||
|
"marketplaceInstalls24h": "24 小時內安裝 {{formattedCount}} 次",
|
||||||
|
"marketplaceInstalls": "安裝 {{formattedCount}} 次",
|
||||||
|
"marketplaceNpxRequired": "需要安裝包含 npx 的 Node.js",
|
||||||
|
"marketplaceInstalling": "正在安裝",
|
||||||
|
"marketplaceInstalled": "已安裝",
|
||||||
|
"marketplaceInstall": "安裝",
|
||||||
|
"marketplaceNoTrend": "暫無趨勢",
|
||||||
"featured": "Agent 技能",
|
"featured": "Agent 技能",
|
||||||
"empty": "目前沒有可用的技能。",
|
"empty": "目前沒有可用的技能。",
|
||||||
"sourceWorkspace": "自訂",
|
"sourceWorkspace": "自訂",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
FilePreviewPayload,
|
FilePreviewPayload,
|
||||||
ImageGenerationSettingsUpdate,
|
ImageGenerationSettingsUpdate,
|
||||||
McpPresetsPayload,
|
McpPresetsPayload,
|
||||||
|
MarketplaceProvider,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
ModelConfigurationCreate,
|
ModelConfigurationCreate,
|
||||||
ModelConfigurationUpdate,
|
ModelConfigurationUpdate,
|
||||||
@ -343,9 +344,10 @@ export async function deleteSkill(
|
|||||||
export async function searchMarketplaceSkills(
|
export async function searchMarketplaceSkills(
|
||||||
token: string,
|
token: string,
|
||||||
query: string,
|
query: string,
|
||||||
|
provider: MarketplaceProvider = "all",
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<SkillsSearchPayload> {
|
): Promise<SkillsSearchPayload> {
|
||||||
const params = new URLSearchParams({ q: query });
|
const params = new URLSearchParams({ q: query, provider });
|
||||||
return request<SkillsSearchPayload>(
|
return request<SkillsSearchPayload>(
|
||||||
`${base}/api/webui/skills/search?${params}`,
|
`${base}/api/webui/skills/search?${params}`,
|
||||||
token,
|
token,
|
||||||
@ -356,10 +358,12 @@ export async function searchMarketplaceSkills(
|
|||||||
|
|
||||||
export async function fetchTrendingMarketplaceSkills(
|
export async function fetchTrendingMarketplaceSkills(
|
||||||
token: string,
|
token: string,
|
||||||
|
provider: MarketplaceProvider = "all",
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<SkillsTrendingPayload> {
|
): Promise<SkillsTrendingPayload> {
|
||||||
|
const params = new URLSearchParams({ provider });
|
||||||
return request<SkillsTrendingPayload>(
|
return request<SkillsTrendingPayload>(
|
||||||
`${base}/api/webui/skills/trending`,
|
`${base}/api/webui/skills/trending?${params}`,
|
||||||
token,
|
token,
|
||||||
undefined,
|
undefined,
|
||||||
API_READ_TIMEOUT_MS,
|
API_READ_TIMEOUT_MS,
|
||||||
@ -383,11 +387,14 @@ export async function fetchMarketplaceSkillTrends(
|
|||||||
|
|
||||||
export async function installMarketplaceSkill(
|
export async function installMarketplaceSkill(
|
||||||
token: string,
|
token: string,
|
||||||
|
provider: Exclude<MarketplaceProvider, "all">,
|
||||||
source: string,
|
source: string,
|
||||||
skill: string,
|
skill: string,
|
||||||
|
version: string = "",
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<SkillInstallPayload> {
|
): Promise<SkillInstallPayload> {
|
||||||
const params = new URLSearchParams({ source, skill });
|
const params = new URLSearchParams({ provider, source, skill });
|
||||||
|
if (version) params.set("version", version);
|
||||||
return request<SkillInstallPayload>(
|
return request<SkillInstallPayload>(
|
||||||
`${base}/api/webui/skills/install?${params}`,
|
`${base}/api/webui/skills/install?${params}`,
|
||||||
token,
|
token,
|
||||||
|
|||||||
@ -219,21 +219,32 @@ export interface MarketplaceSkillSummary {
|
|||||||
skill_id: string;
|
skill_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
provider: Exclude<MarketplaceProvider, "all">;
|
||||||
installs: number;
|
installs: number;
|
||||||
|
downloads?: number;
|
||||||
url: string;
|
url: string;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
|
install_supported: boolean;
|
||||||
|
metric: "installs_24h" | "installs_total";
|
||||||
|
version?: string;
|
||||||
|
verified?: boolean;
|
||||||
|
requires_api_key?: boolean;
|
||||||
rank?: number;
|
rank?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MarketplaceProvider = "all" | "skills_sh" | "skillhub";
|
||||||
|
|
||||||
export interface SkillsSearchPayload {
|
export interface SkillsSearchPayload {
|
||||||
query: string;
|
query: string;
|
||||||
skills: MarketplaceSkillSummary[];
|
skills: MarketplaceSkillSummary[];
|
||||||
|
provider: MarketplaceProvider;
|
||||||
install_supported: boolean;
|
install_supported: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsTrendingPayload {
|
export interface SkillsTrendingPayload {
|
||||||
skills: MarketplaceSkillSummary[];
|
skills: MarketplaceSkillSummary[];
|
||||||
period: "24h";
|
period: "24h" | "trending" | "mixed";
|
||||||
|
provider: MarketplaceProvider;
|
||||||
install_supported: boolean;
|
install_supported: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -293,22 +293,22 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("encodes skills.sh search queries", async () => {
|
it("encodes marketplace search queries and provider", async () => {
|
||||||
await searchMarketplaceSkills("tok", "React & testing");
|
await searchMarketplaceSkills("tok", "React & testing");
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/webui/skills/search?q=React+%26+testing",
|
"/api/webui/skills/search?q=React+%26+testing&provider=all",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: { Authorization: "Bearer tok" },
|
headers: { Authorization: "Bearer tok" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches the skills.sh 24-hour leaderboard", async () => {
|
it("fetches a provider marketplace leaderboard", async () => {
|
||||||
await fetchTrendingMarketplaceSkills("tok");
|
await fetchTrendingMarketplaceSkills("tok", "skillhub");
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/webui/skills/trending",
|
"/api/webui/skills/trending?provider=skillhub",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: { Authorization: "Bearer tok" },
|
headers: { Authorization: "Bearer tok" },
|
||||||
}),
|
}),
|
||||||
@ -329,11 +329,17 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("encodes skills.sh install coordinates", async () => {
|
it("encodes provider install coordinates", async () => {
|
||||||
await installMarketplaceSkill("tok", "vercel-labs/agent-skills", "react-testing");
|
await installMarketplaceSkill(
|
||||||
|
"tok",
|
||||||
|
"skillhub",
|
||||||
|
"@tencent/skills",
|
||||||
|
"ima-skills",
|
||||||
|
"1.1.8",
|
||||||
|
);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/webui/skills/install?source=vercel-labs%2Fagent-skills&skill=react-testing",
|
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: { Authorization: "Bearer tok" },
|
headers: { Authorization: "Bearer tok" },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -606,42 +606,84 @@ describe("App layout", () => {
|
|||||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"/api/webui/skills/trending": {
|
"/api/webui/skills/trending?provider=all": {
|
||||||
period: "24h",
|
period: "mixed",
|
||||||
|
provider: "all",
|
||||||
install_supported: true,
|
install_supported: true,
|
||||||
skills: [{
|
skills: [
|
||||||
id: "vercel-labs/skills/find-skills",
|
{
|
||||||
skill_id: "find-skills",
|
id: "vercel-labs/skills/find-skills",
|
||||||
name: "find-skills",
|
skill_id: "find-skills",
|
||||||
source: "vercel-labs/skills",
|
name: "find-skills",
|
||||||
installs: 14_481,
|
source: "vercel-labs/skills",
|
||||||
url: "https://skills.sh/vercel-labs/skills/find-skills",
|
provider: "skills_sh",
|
||||||
installed: false,
|
installs: 14_481,
|
||||||
rank: 18,
|
url: "https://skills.sh/vercel-labs/skills/find-skills",
|
||||||
}],
|
installed: false,
|
||||||
|
install_supported: true,
|
||||||
|
metric: "installs_24h",
|
||||||
|
rank: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skillhub:ima-skills",
|
||||||
|
skill_id: "ima-skills",
|
||||||
|
name: "ima-skills",
|
||||||
|
source: "@tencent-adm/ima-skills",
|
||||||
|
provider: "skillhub",
|
||||||
|
installs: 11_831,
|
||||||
|
downloads: 142_525,
|
||||||
|
url: "https://skillhub.cn/tencent-adm/ima-skills",
|
||||||
|
installed: false,
|
||||||
|
install_supported: true,
|
||||||
|
metric: "installs_total",
|
||||||
|
version: "1.1.8",
|
||||||
|
verified: true,
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills": {
|
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills": {
|
||||||
trends: {
|
trends: {
|
||||||
"vercel-labs/skills/find-skills": [20, 32, 28, 45, 41, 50, 62, 58],
|
"vercel-labs/skills/find-skills": [20, 32, 28, 45, 41, 50, 62, 58],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/webui/skills/search?q=React": {
|
"/api/webui/skills/search?q=React&provider=all": {
|
||||||
query: "React",
|
query: "React",
|
||||||
|
provider: "all",
|
||||||
install_supported: true,
|
install_supported: true,
|
||||||
skills: [{
|
skills: [
|
||||||
id: "acme/agent-skills/react-testing",
|
{
|
||||||
skill_id: "react-testing",
|
id: "acme/agent-skills/react-testing",
|
||||||
name: "React Testing",
|
skill_id: "react-testing",
|
||||||
source: "acme/agent-skills",
|
name: "React Testing",
|
||||||
installs: 42,
|
source: "acme/agent-skills",
|
||||||
url: "https://skills.sh/acme/agent-skills/react-testing",
|
provider: "skills_sh",
|
||||||
installed: false,
|
installs: 42,
|
||||||
}],
|
url: "https://skills.sh/acme/agent-skills/react-testing",
|
||||||
|
installed: false,
|
||||||
|
install_supported: true,
|
||||||
|
metric: "installs_total",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skillhub:react",
|
||||||
|
skill_id: "react",
|
||||||
|
name: "React",
|
||||||
|
source: "@ivangdavila/react",
|
||||||
|
provider: "skillhub",
|
||||||
|
installs: 693,
|
||||||
|
downloads: 7_718,
|
||||||
|
url: "https://skillhub.cn/ivangdavila/react",
|
||||||
|
installed: false,
|
||||||
|
install_supported: true,
|
||||||
|
metric: "installs_total",
|
||||||
|
version: "1.0.4",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
||||||
trends: { "acme/agent-skills/react-testing": [] },
|
trends: { "acme/agent-skills/react-testing": [] },
|
||||||
},
|
},
|
||||||
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing": {
|
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing": {
|
||||||
skills: [
|
skills: [
|
||||||
{
|
{
|
||||||
name: "react-testing",
|
name: "react-testing",
|
||||||
@ -667,18 +709,23 @@ describe("App layout", () => {
|
|||||||
const discoverTab = await screen.findByRole("tab", { name: "Discover" });
|
const discoverTab = await screen.findByRole("tab", { name: "Discover" });
|
||||||
expect(discoverTab.querySelector("svg")).toBeNull();
|
expect(discoverTab.querySelector("svg")).toBeNull();
|
||||||
fireEvent.click(discoverTab);
|
fireEvent.click(discoverTab);
|
||||||
expect(await screen.findByRole("heading", { name: "Trending today" })).toBeInTheDocument();
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Trending by marketplace" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("find-skills")).toBeInTheDocument();
|
expect(screen.getByText("find-skills")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("ima-skills")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("SkillHub").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("skills.sh").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument();
|
expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("img", { name: "8-week install trend" }),
|
await screen.findByRole("img", { name: "8-week install trend" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills.sh" }), {
|
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), {
|
||||||
target: { value: "React" },
|
target: { value: "React" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText("React Testing")).toBeInTheDocument();
|
expect(await screen.findByText("React Testing")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Install" }));
|
fireEvent.click(screen.getByRole("button", { name: "Install React Testing" }));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Install React Testing?" }),
|
await screen.findByRole("heading", { name: "Install React Testing?" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@ -686,13 +733,15 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing",
|
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: { Authorization: expect.any(String) },
|
headers: { Authorization: expect.any(String) },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole("button", { name: "Installed" })).toBeDisabled();
|
expect(
|
||||||
|
await screen.findByRole("button", { name: "Installed React Testing" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||||
expect(screen.getByText("react-testing")).toBeInTheDocument();
|
expect(screen.getByText("react-testing")).toBeInTheDocument();
|
||||||
|
|||||||
@ -78,6 +78,29 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
|||||||
"settings.skills.views",
|
"settings.skills.views",
|
||||||
"settings.skills.installedTab",
|
"settings.skills.installedTab",
|
||||||
"settings.skills.discoverTab",
|
"settings.skills.discoverTab",
|
||||||
|
"settings.skills.marketplaceSearchFailed",
|
||||||
|
"settings.skills.marketplaceInstallFailed",
|
||||||
|
"settings.skills.marketplaceSearchPlaceholder",
|
||||||
|
"settings.skills.marketplaceSearchLabel",
|
||||||
|
"settings.skills.marketplaceSearching",
|
||||||
|
"settings.skills.marketplaceProviderFilter",
|
||||||
|
"settings.skills.marketplaceProviderAll",
|
||||||
|
"settings.skills.marketplaceTrendingTitle",
|
||||||
|
"settings.skills.marketplaceTrendingDescription",
|
||||||
|
"settings.skills.marketplaceViewAll",
|
||||||
|
"settings.skills.marketplaceTrendingUnavailable",
|
||||||
|
"settings.skills.marketplaceEmpty",
|
||||||
|
"settings.skills.marketplaceConfirmTitle",
|
||||||
|
"settings.skills.marketplaceConfirmDescription",
|
||||||
|
"settings.skills.marketplaceConfirmInstall",
|
||||||
|
"settings.skills.marketplaceOpen",
|
||||||
|
"settings.skills.marketplaceInstalls24h",
|
||||||
|
"settings.skills.marketplaceInstalls",
|
||||||
|
"settings.skills.marketplaceNpxRequired",
|
||||||
|
"settings.skills.marketplaceInstalling",
|
||||||
|
"settings.skills.marketplaceInstalled",
|
||||||
|
"settings.skills.marketplaceInstall",
|
||||||
|
"settings.skills.marketplaceNoTrend",
|
||||||
"settings.nanobotFeatures.disable",
|
"settings.nanobotFeatures.disable",
|
||||||
"settings.nanobotFeatures.ready",
|
"settings.nanobotFeatures.ready",
|
||||||
"settings.nanobotFeatures.missingDependency",
|
"settings.nanobotFeatures.missingDependency",
|
||||||
@ -442,6 +465,10 @@ describe("webui i18n", () => {
|
|||||||
expect(settings.overview.workspace).toBe("工作区");
|
expect(settings.overview.workspace).toBe("工作区");
|
||||||
expect(settings.skills.installedTab).toBe("已安装");
|
expect(settings.skills.installedTab).toBe("已安装");
|
||||||
expect(settings.skills.discoverTab).toBe("发现");
|
expect(settings.skills.discoverTab).toBe("发现");
|
||||||
|
expect(settings.skills.marketplaceProviderFilter).toBe("技能来源");
|
||||||
|
expect(settings.skills.marketplaceProviderAll).toBe("全部");
|
||||||
|
expect(settings.skills.marketplaceSearchPlaceholder).toBe("搜索技能");
|
||||||
|
expect(settings.skills.marketplaceTrendingTitle).toBe("各市场热门技能");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Brazilian Portuguese settings overview copy localized", () => {
|
it("keeps Brazilian Portuguese settings overview copy localized", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user