mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
feat(webui): add skills marketplace
This commit is contained in:
parent
129b74b4cf
commit
ba0ba4749d
@ -70,6 +70,7 @@ class ContextBuilder:
|
||||
def build_system_prompt(
|
||||
self,
|
||||
*,
|
||||
active_skill_names: Sequence[str] | None = None,
|
||||
channel: str | None = None,
|
||||
session_summary: str | None = None,
|
||||
workspace: Path | None = None,
|
||||
@ -91,13 +92,18 @@ class ContextBuilder:
|
||||
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
||||
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
||||
|
||||
always_skills = self.skills.get_always_skills()
|
||||
if always_skills:
|
||||
always_content = self.skills.load_skills_for_context(always_skills)
|
||||
if always_content:
|
||||
parts.append(f"# Active Skills\n\n{always_content}")
|
||||
active_skills = self.skills.get_always_skills()
|
||||
active_skills.extend(
|
||||
name
|
||||
for name in (active_skill_names or ())
|
||||
if name not in active_skills
|
||||
)
|
||||
if active_skills:
|
||||
active_content = self.skills.load_skills_for_context(active_skills)
|
||||
if active_content:
|
||||
parts.append(f"# Active Skills\n\n{active_content}")
|
||||
|
||||
skills_summary = self.skills.build_skills_summary(exclude=set(always_skills))
|
||||
skills_summary = self.skills.build_skills_summary(exclude=set(active_skills))
|
||||
if skills_summary:
|
||||
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
|
||||
|
||||
@ -214,6 +220,11 @@ class ContextBuilder:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
root = workspace or self.workspace
|
||||
active_skill_names = (
|
||||
self.skills.get_explicitly_invoked_skills(current_message)
|
||||
if current_role == "user"
|
||||
else []
|
||||
)
|
||||
user_content = self.build_user_content(current_message, image_paths=media)
|
||||
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
||||
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
|
||||
@ -221,6 +232,7 @@ class ContextBuilder:
|
||||
{
|
||||
"role": "system",
|
||||
"content": self.build_system_prompt(
|
||||
active_skill_names=active_skill_names,
|
||||
channel=channel,
|
||||
session_summary=session_summary,
|
||||
workspace=root,
|
||||
|
||||
@ -17,6 +17,7 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
||||
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
@ -109,6 +110,21 @@ class SkillsLoader:
|
||||
]
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
def get_explicitly_invoked_skills(self, text: str) -> list[str]:
|
||||
"""Resolve ``$skill-name`` references to enabled, available skills."""
|
||||
if not text:
|
||||
return []
|
||||
available = {
|
||||
entry["name"]
|
||||
for entry in self.list_skills(filter_unavailable=True)
|
||||
}
|
||||
invoked: list[str] = []
|
||||
for match in _SKILL_REFERENCE.finditer(text):
|
||||
name = match.group(1)
|
||||
if name in available and name not in invoked:
|
||||
invoked.append(name)
|
||||
return invoked
|
||||
|
||||
def build_skills_summary(self, exclude: set[str] | None = None) -> str:
|
||||
"""
|
||||
Build a summary of all skills (name, description, path, availability).
|
||||
|
||||
@ -100,6 +100,7 @@ class ChannelManager:
|
||||
webui_static_dist: bool = True,
|
||||
webui_runtime_surface: str = "browser",
|
||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
):
|
||||
self.config = config
|
||||
self.bus = bus
|
||||
@ -112,6 +113,7 @@ class ChannelManager:
|
||||
self._webui_static_dist = webui_static_dist
|
||||
self._webui_runtime_surface = webui_runtime_surface
|
||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||
self._webui_skill_state_action = webui_skill_state_action
|
||||
self.channels: dict[str, BaseChannel] = {}
|
||||
self._channel_owners: dict[str, str] = {}
|
||||
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
||||
@ -178,6 +180,7 @@ class ChannelManager:
|
||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||
channel_feature_action=self.apply_channel_feature_action,
|
||||
channel_runtime_status=self.get_status,
|
||||
skill_state_action=self._webui_skill_state_action,
|
||||
logger=logger,
|
||||
)
|
||||
kwargs["gateway"] = gateway
|
||||
|
||||
@ -519,6 +519,8 @@ async def test_webui_skills_route_requires_token_and_hides_paths(
|
||||
"name": "workspace-skill",
|
||||
"description": "Workspace skill.",
|
||||
"source": "workspace",
|
||||
"enabled": True,
|
||||
"deletable": True,
|
||||
"available": True,
|
||||
"unavailable_reason": "",
|
||||
}
|
||||
@ -548,6 +550,212 @@ async def test_webui_skills_route_requires_token_and_hides_paths(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_skill_management_routes(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
skill_dir = tmp_path / "skills" / "custom-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: custom-skill\ndescription: Custom skill.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def set_enabled(
|
||||
workspace: Path,
|
||||
name: str,
|
||||
*,
|
||||
enabled: bool,
|
||||
disabled_skills: set[str],
|
||||
) -> dict[str, Any]:
|
||||
assert workspace == tmp_path
|
||||
assert name == "custom-skill"
|
||||
assert enabled is False
|
||||
disabled_skills.add(name)
|
||||
return {"name": name, "enabled": enabled, "deleted": False}
|
||||
|
||||
def delete(
|
||||
workspace: Path,
|
||||
name: str,
|
||||
*,
|
||||
disabled_skills: set[str],
|
||||
) -> dict[str, Any]:
|
||||
assert workspace == tmp_path
|
||||
assert name == "custom-skill"
|
||||
disabled_skills.discard(name)
|
||||
for child in skill_dir.iterdir():
|
||||
child.unlink()
|
||||
skill_dir.rmdir()
|
||||
return {"name": name, "enabled": False, "deleted": True}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.set_webui_skill_enabled", set_enabled)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.delete_webui_skill", delete)
|
||||
|
||||
port = _free_port()
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
workspace_path=tmp_path,
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
update_response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/webui/skills/update"
|
||||
"?name=custom-skill&enabled=false",
|
||||
headers=headers,
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["last_action"]["enabled"] is False
|
||||
custom = next(
|
||||
item
|
||||
for item in update_response.json()["skills"]
|
||||
if item["name"] == "custom-skill"
|
||||
)
|
||||
assert custom["enabled"] is False
|
||||
|
||||
delete_response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/webui/skills/delete"
|
||||
"?name=custom-skill",
|
||||
headers=headers,
|
||||
)
|
||||
assert delete_response.status_code == 200
|
||||
assert delete_response.json()["last_action"]["deleted"] is True
|
||||
assert all(
|
||||
item["name"] != "custom-skill"
|
||||
for item in delete_response.json()["skills"]
|
||||
)
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_skills_marketplace_routes_search_and_install(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
search = AsyncMock(return_value={
|
||||
"query": "react",
|
||||
"install_supported": True,
|
||||
"skills": [{
|
||||
"id": "acme/agent-skills/react-testing",
|
||||
"skill_id": "react-testing",
|
||||
"name": "React Testing",
|
||||
"source": "acme/agent-skills",
|
||||
"installs": 42,
|
||||
"url": "https://skills.sh/acme/agent-skills/react-testing",
|
||||
"installed": False,
|
||||
}],
|
||||
})
|
||||
trending = AsyncMock(return_value={
|
||||
"period": "24h",
|
||||
"install_supported": True,
|
||||
"skills": [{
|
||||
"id": "acme/agent-skills/react-testing",
|
||||
"skill_id": "react-testing",
|
||||
"name": "React Testing",
|
||||
"source": "acme/agent-skills",
|
||||
"installs": 12,
|
||||
"url": "https://skills.sh/acme/agent-skills/react-testing",
|
||||
"installed": False,
|
||||
"rank": 1,
|
||||
}],
|
||||
})
|
||||
trends = AsyncMock(return_value={
|
||||
"trends": {"acme/agent-skills/react-testing": [2, 4, 3, 8]},
|
||||
})
|
||||
|
||||
async def install(source: str, skill_id: str, workspace: Path) -> dict[str, Any]:
|
||||
assert source == "acme/agent-skills"
|
||||
assert skill_id == "react-testing"
|
||||
assert workspace == tmp_path
|
||||
skill_dir = workspace / "skills" / skill_id
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: react-testing\ndescription: Test React apps.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {"installed": True, "already_installed": False, "name": skill_id}
|
||||
|
||||
install_mock = AsyncMock(side_effect=install)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.search_marketplace_skills", search)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.trending_marketplace_skills", trending)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.marketplace_skill_trends", trends)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.install_marketplace_skill", install_mock)
|
||||
|
||||
port = _free_port()
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
workspace_path=tmp_path,
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
denied = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/webui/skills/search?q=react"
|
||||
)
|
||||
assert denied.status_code == 401
|
||||
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
search_response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/webui/skills/search?q=react",
|
||||
headers=headers,
|
||||
)
|
||||
assert search_response.status_code == 200
|
||||
assert search_response.json()["skills"][0]["skill_id"] == "react-testing"
|
||||
search.assert_awaited_once_with("react", tmp_path)
|
||||
|
||||
trending_response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/webui/skills/trending",
|
||||
headers=headers,
|
||||
)
|
||||
assert trending_response.status_code == 200
|
||||
assert trending_response.json()["period"] == "24h"
|
||||
trending.assert_awaited_once_with(tmp_path)
|
||||
|
||||
trends_response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/webui/skills/trends"
|
||||
"?id=acme%2Fagent-skills%2Freact-testing",
|
||||
headers=headers,
|
||||
)
|
||||
assert trends_response.status_code == 200
|
||||
assert trends_response.json()["trends"] == {
|
||||
"acme/agent-skills/react-testing": [2, 4, 3, 8],
|
||||
}
|
||||
trends.assert_awaited_once_with(["acme/agent-skills/react-testing"])
|
||||
|
||||
params = urlencode({
|
||||
"source": "acme/agent-skills",
|
||||
"skill": "react-testing",
|
||||
})
|
||||
install_response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/webui/skills/install?{params}",
|
||||
headers=headers,
|
||||
)
|
||||
assert install_response.status_code == 200
|
||||
body = install_response.json()
|
||||
assert body["last_action"] == {
|
||||
"installed": True,
|
||||
"already_installed": False,
|
||||
"name": "react-testing",
|
||||
}
|
||||
assert next(
|
||||
skill for skill in body["skills"] if skill["name"] == "react-testing"
|
||||
)["source"] == "workspace"
|
||||
install_mock.assert_awaited_once()
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_apps_routes_require_token_and_return_payload(
|
||||
bus: MagicMock,
|
||||
|
||||
@ -2139,6 +2139,11 @@ def _run_gateway(
|
||||
def _webui_runtime_model_name() -> str | None:
|
||||
return agent.model.strip() or None
|
||||
|
||||
def _webui_skill_state_action(disabled_skills: set[str]) -> None:
|
||||
config.agents.defaults.disabled_skills = sorted(disabled_skills)
|
||||
agent.context.skills.disabled_skills = set(disabled_skills)
|
||||
agent.subagents.disabled_skills = set(disabled_skills)
|
||||
|
||||
# Create channel manager (forwards SessionManager so the WebSocket channel
|
||||
# can serve the embedded webui's REST surface).
|
||||
channels = ChannelManager(
|
||||
@ -2153,6 +2158,7 @@ def _run_gateway(
|
||||
webui_static_dist=webui_static_dist,
|
||||
webui_runtime_surface=webui_runtime_surface,
|
||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||
webui_skill_state_action=_webui_skill_state_action,
|
||||
)
|
||||
|
||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||
|
||||
@ -58,6 +58,7 @@ def build_gateway_services(
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
logger: Any = default_logger,
|
||||
) -> GatewayServices:
|
||||
tokens = GatewayTokenStore()
|
||||
@ -101,6 +102,7 @@ def build_gateway_services(
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
channel_feature_action=channel_feature_action,
|
||||
channel_runtime_status=channel_runtime_status,
|
||||
skill_state_action=skill_state_action,
|
||||
log=logger,
|
||||
)
|
||||
return GatewayServices(
|
||||
|
||||
@ -2,10 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
|
||||
|
||||
class SkillManagementError(Exception):
|
||||
"""A safe skill-management error for the WebUI."""
|
||||
|
||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def webui_skills_payload(
|
||||
@ -14,12 +27,17 @@ def webui_skills_payload(
|
||||
disabled_skills: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return agent skills without leaking local filesystem paths."""
|
||||
loader = SkillsLoader(workspace_path, disabled_skills=disabled_skills)
|
||||
loader = SkillsLoader(workspace_path)
|
||||
entries = sorted(
|
||||
loader.list_skills(filter_unavailable=False),
|
||||
key=lambda entry: (entry.get("source") != "workspace", entry["name"]),
|
||||
)
|
||||
return {"skills": [_skill_payload(loader, entry) for entry in entries]}
|
||||
return {
|
||||
"skills": [
|
||||
_skill_payload(loader, entry, disabled_skills=disabled_skills)
|
||||
for entry in entries
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def webui_skill_detail_payload(
|
||||
@ -29,26 +47,114 @@ def webui_skill_detail_payload(
|
||||
disabled_skills: set[str] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a single skill's safe detail payload."""
|
||||
loader = SkillsLoader(workspace_path, disabled_skills=disabled_skills)
|
||||
loader = SkillsLoader(workspace_path)
|
||||
entries = loader.list_skills(filter_unavailable=False)
|
||||
entry = next((item for item in entries if item["name"] == name), None)
|
||||
if entry is None:
|
||||
return None
|
||||
metadata = loader.get_skill_metadata(name)
|
||||
return {
|
||||
**_skill_payload(loader, entry),
|
||||
**_skill_payload(
|
||||
loader,
|
||||
entry,
|
||||
metadata=metadata,
|
||||
disabled_skills=disabled_skills,
|
||||
),
|
||||
"requirements": loader.get_skill_requirements(name),
|
||||
"install_options": _install_options(metadata),
|
||||
"raw_markdown": loader.load_skill(name) or "",
|
||||
}
|
||||
|
||||
|
||||
def _skill_payload(loader: SkillsLoader, entry: dict[str, str]) -> dict[str, Any]:
|
||||
def set_webui_skill_enabled(
|
||||
workspace_path: Path,
|
||||
name: str,
|
||||
*,
|
||||
enabled: bool,
|
||||
disabled_skills: set[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Persist and apply one skill's enabled state."""
|
||||
_require_skill_entry(workspace_path, name)
|
||||
config = load_config()
|
||||
next_disabled = set(config.agents.defaults.disabled_skills)
|
||||
if enabled:
|
||||
next_disabled.discard(name)
|
||||
else:
|
||||
next_disabled.add(name)
|
||||
if next_disabled != set(config.agents.defaults.disabled_skills):
|
||||
config.agents.defaults.disabled_skills = sorted(next_disabled)
|
||||
save_config(config)
|
||||
disabled_skills.clear()
|
||||
disabled_skills.update(next_disabled)
|
||||
return {"name": name, "enabled": enabled, "deleted": False}
|
||||
|
||||
|
||||
def delete_webui_skill(
|
||||
workspace_path: Path,
|
||||
name: str,
|
||||
*,
|
||||
disabled_skills: set[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Delete one workspace skill and remove its disabled-state entry."""
|
||||
entry = _require_skill_entry(workspace_path, name)
|
||||
if entry.get("source") != "workspace":
|
||||
raise SkillManagementError("built-in skills cannot be deleted", status=403)
|
||||
|
||||
skills_root = (workspace_path.expanduser().resolve() / "skills").resolve()
|
||||
target = skills_root / name
|
||||
if target.parent != skills_root:
|
||||
raise SkillManagementError("invalid skill name")
|
||||
if target.is_symlink():
|
||||
target.unlink()
|
||||
elif target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
raise SkillManagementError("skill directory was not found", status=404)
|
||||
|
||||
config = load_config()
|
||||
next_disabled = set(config.agents.defaults.disabled_skills)
|
||||
if name in next_disabled:
|
||||
next_disabled.remove(name)
|
||||
config.agents.defaults.disabled_skills = sorted(next_disabled)
|
||||
save_config(config)
|
||||
disabled_skills.clear()
|
||||
disabled_skills.update(next_disabled)
|
||||
return {"name": name, "enabled": False, "deleted": True}
|
||||
|
||||
|
||||
def _require_skill_entry(workspace_path: Path, name: str) -> dict[str, str]:
|
||||
if not name or "/" in name or "\\" in name:
|
||||
raise SkillManagementError("invalid skill name")
|
||||
entry = next(
|
||||
(
|
||||
item
|
||||
for item in SkillsLoader(workspace_path).list_skills(filter_unavailable=False)
|
||||
if item["name"] == name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
raise SkillManagementError("skill not found", status=404)
|
||||
return entry
|
||||
|
||||
|
||||
def _skill_payload(
|
||||
loader: SkillsLoader,
|
||||
entry: dict[str, str],
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
disabled_skills: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = entry["name"]
|
||||
metadata = loader.get_skill_metadata(name)
|
||||
metadata = metadata if metadata is not None else loader.get_skill_metadata(name)
|
||||
available, unavailable_reason = loader.get_skill_availability(name)
|
||||
source = entry.get("source", "unknown")
|
||||
return {
|
||||
"name": name,
|
||||
"description": _description(metadata, name),
|
||||
"source": entry.get("source", "unknown"),
|
||||
"source": source,
|
||||
"enabled": name not in (disabled_skills or set()),
|
||||
"deletable": source == "workspace",
|
||||
"available": available,
|
||||
"unavailable_reason": unavailable_reason,
|
||||
}
|
||||
@ -59,3 +165,51 @@ def _description(metadata: dict[str, Any] | None, fallback: str) -> str:
|
||||
return fallback
|
||||
value = metadata.get("description")
|
||||
return value.strip() if isinstance(value, str) and value.strip() else fallback
|
||||
|
||||
|
||||
def _nanobot_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if metadata is None:
|
||||
return {}
|
||||
raw = metadata.get("metadata")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
payload = raw.get("nanobot", raw.get("openclaw", {}))
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _install_options(metadata: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||
"""Return safe, copyable setup commands declared by a skill."""
|
||||
install = _nanobot_metadata(metadata).get("install")
|
||||
if not isinstance(install, list):
|
||||
return []
|
||||
|
||||
options: list[dict[str, str]] = []
|
||||
for item in install:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
kind = item.get("kind")
|
||||
package = item.get("formula") if kind == "brew" else item.get("package")
|
||||
if not isinstance(package, str) or not package.strip():
|
||||
continue
|
||||
if kind == "brew":
|
||||
command = f"brew install {shlex.quote(package.strip())}"
|
||||
elif kind == "apt":
|
||||
command = f"sudo apt-get install -y {shlex.quote(package.strip())}"
|
||||
else:
|
||||
continue
|
||||
option_id = item.get("id")
|
||||
label = item.get("label")
|
||||
options.append(
|
||||
{
|
||||
"id": option_id if isinstance(option_id, str) else kind,
|
||||
"kind": kind,
|
||||
"label": label if isinstance(label, str) else f"Install with {kind}",
|
||||
"command": command,
|
||||
}
|
||||
)
|
||||
return options
|
||||
|
||||
395
nanobot/webui/skills_marketplace.py
Normal file
395
nanobot/webui/skills_marketplace.py
Normal file
@ -0,0 +1,395 @@
|
||||
"""Search and install skills from the skills.sh catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.security.network import PinnedDNSAsyncTransport
|
||||
|
||||
_SEARCH_URL = "https://skills.sh/api/search"
|
||||
_TRENDING_URL = "https://skills.sh/api/skills/trending/0"
|
||||
_SKILL_PAGE_BASE_URL = "https://www.skills.sh"
|
||||
_ALL_TIME_URLS = (
|
||||
"https://skills.sh/api/skills/all-time/0",
|
||||
"https://skills.sh/api/skills/all-time/1",
|
||||
)
|
||||
_TREND_VALUES_RE = re.compile(r'\\"values\\":\s*\[([0-9,\s]+)\]')
|
||||
_SOURCE_RE = re.compile(
|
||||
r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[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]+)*$")
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
_INSTALL_TIMEOUT_SECONDS = 120
|
||||
_WEEKLY_CACHE_TTL_SECONDS = 300
|
||||
# The skills CLI's OpenClaw adapter copies into <workspace>/skills, nanobot's layout too.
|
||||
_CLI_AGENT = "openclaw"
|
||||
_weekly_cache: dict[tuple[str, str], list[int]] = {}
|
||||
_weekly_cache_expires_at = 0.0
|
||||
|
||||
|
||||
class SkillsMarketplaceError(Exception):
|
||||
"""A safe error that can be returned to the WebUI."""
|
||||
|
||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def skills_install_supported() -> bool:
|
||||
"""Return whether the official skills CLI can be launched."""
|
||||
return shutil.which("npx") is not None
|
||||
|
||||
|
||||
async def trending_marketplace_skills(
|
||||
workspace_path: Path,
|
||||
*,
|
||||
limit: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a source-diverse snapshot of skills.sh's real 24-hour leaderboard."""
|
||||
try:
|
||||
async with _skills_client() as client:
|
||||
response = await client.get(_TRENDING_URL)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise SkillsMarketplaceError(
|
||||
"skills.sh 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]] = []
|
||||
seen_sources: set[str] = set()
|
||||
for rank, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
source = row.get("source")
|
||||
if not isinstance(source, str) or source in seen_sources:
|
||||
continue
|
||||
skill = _marketplace_skill(row, installed, rank=rank)
|
||||
if skill is None:
|
||||
continue
|
||||
seen_sources.add(source)
|
||||
skills.append(skill)
|
||||
if len(skills) >= min(max(limit, 1), 20):
|
||||
break
|
||||
|
||||
return {
|
||||
"skills": skills,
|
||||
"period": "24h",
|
||||
"install_supported": skills_install_supported(),
|
||||
}
|
||||
|
||||
|
||||
async def search_marketplace_skills(
|
||||
query: str,
|
||||
workspace_path: Path,
|
||||
*,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Search skills.sh and annotate results already installed in this workspace."""
|
||||
normalized = " ".join(query.split())
|
||||
if len(normalized) < 2:
|
||||
raise SkillsMarketplaceError("search query must contain at least 2 characters")
|
||||
if len(normalized) > 100:
|
||||
raise SkillsMarketplaceError("search query is too long")
|
||||
|
||||
try:
|
||||
async with _skills_client() as client:
|
||||
response = await client.get(
|
||||
_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(
|
||||
"skills.sh search is temporarily unavailable",
|
||||
status=502,
|
||||
) from exc
|
||||
|
||||
installed = _installed_skill_names(workspace_path)
|
||||
rows = payload.get("skills", []) if isinstance(payload, dict) else []
|
||||
skills = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
skill = _marketplace_skill(row, installed)
|
||||
if skill is not None:
|
||||
skills.append(skill)
|
||||
|
||||
return {
|
||||
"query": normalized,
|
||||
"skills": skills,
|
||||
"install_supported": skills_install_supported(),
|
||||
}
|
||||
|
||||
|
||||
async def marketplace_skill_trends(
|
||||
skill_ids: list[str] | None = None,
|
||||
) -> dict[str, dict[str, list[int]]]:
|
||||
"""Return install history independently, filling requested cache misses."""
|
||||
requested = _valid_skill_refs(skill_ids or [])
|
||||
async with _skills_client() as client:
|
||||
weekly_installs = await _load_weekly_installs(client)
|
||||
missing = [ref for ref in requested if ref not in weekly_installs]
|
||||
if missing:
|
||||
weekly_installs.update(await _load_skill_page_trends(client, missing))
|
||||
|
||||
selected = requested or list(weekly_installs)
|
||||
return {
|
||||
"trends": {
|
||||
f"{source}/{skill_id}": values
|
||||
for source, skill_id in selected
|
||||
if (values := weekly_installs.get((source, skill_id))) is not None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def install_marketplace_skill(
|
||||
source: str,
|
||||
skill_id: str,
|
||||
workspace_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Install one skills.sh result into ``<workspace>/skills``."""
|
||||
if not _SOURCE_RE.fullmatch(source):
|
||||
raise SkillsMarketplaceError("invalid skill source")
|
||||
if not _valid_skill_id(skill_id):
|
||||
raise SkillsMarketplaceError("invalid skill name")
|
||||
|
||||
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}
|
||||
|
||||
npx = shutil.which("npx")
|
||||
if npx is None:
|
||||
raise SkillsMarketplaceError(
|
||||
"Node.js with npx is required to install skills",
|
||||
status=503,
|
||||
)
|
||||
|
||||
workspace = workspace_path.expanduser().resolve()
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
env["DISABLE_TELEMETRY"] = "1"
|
||||
command = (
|
||||
npx,
|
||||
"--yes",
|
||||
"skills@latest",
|
||||
"add",
|
||||
source,
|
||||
"--skill",
|
||||
skill_id,
|
||||
"--agent",
|
||||
_CLI_AGENT,
|
||||
"--copy",
|
||||
"--yes",
|
||||
)
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
cwd=str(workspace),
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
output, _ = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=_INSTALL_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
process.kill()
|
||||
await process.communicate()
|
||||
raise SkillsMarketplaceError("skill installation timed out", status=504) from exc
|
||||
|
||||
if process.returncode != 0:
|
||||
detail = _safe_output_tail(output)
|
||||
message = "skill installation failed"
|
||||
if detail:
|
||||
message = f"{message}: {detail}"
|
||||
raise SkillsMarketplaceError(message, status=502)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def _skills_client() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=PinnedDNSAsyncTransport(),
|
||||
timeout=10.0,
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def _installed_skill_names(workspace_path: Path) -> set[str]:
|
||||
return {
|
||||
entry["name"]
|
||||
for entry in SkillsLoader(workspace_path).list_skills(filter_unavailable=False)
|
||||
}
|
||||
|
||||
|
||||
def _marketplace_skill(
|
||||
row: dict[str, Any],
|
||||
installed: set[str],
|
||||
*,
|
||||
rank: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
source = row.get("source")
|
||||
skill_id = row.get("skillId")
|
||||
if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source):
|
||||
return None
|
||||
if not isinstance(skill_id, str) or not _valid_skill_id(skill_id):
|
||||
return None
|
||||
display_name = row.get("name")
|
||||
if not isinstance(display_name, str) or not display_name.strip():
|
||||
display_name = skill_id
|
||||
installs = row.get("installs")
|
||||
skill: dict[str, Any] = {
|
||||
"id": f"{source}/{skill_id}",
|
||||
"skill_id": skill_id,
|
||||
"name": display_name.strip(),
|
||||
"source": source,
|
||||
"installs": installs if isinstance(installs, int) and installs >= 0 else 0,
|
||||
"url": f"https://skills.sh/{source}/{skill_id}",
|
||||
"installed": skill_id in installed,
|
||||
}
|
||||
if rank is not None:
|
||||
skill["rank"] = rank
|
||||
return skill
|
||||
|
||||
|
||||
async def _load_weekly_installs(
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict[tuple[str, str], list[int]]:
|
||||
global _weekly_cache, _weekly_cache_expires_at
|
||||
|
||||
now = time.monotonic()
|
||||
if now < _weekly_cache_expires_at:
|
||||
return _weekly_cache
|
||||
|
||||
responses = await asyncio.gather(
|
||||
*(client.get(url) for url in _ALL_TIME_URLS),
|
||||
return_exceptions=True,
|
||||
)
|
||||
history: dict[tuple[str, str], list[int]] = {}
|
||||
successful = False
|
||||
for response in responses:
|
||||
if isinstance(response, BaseException):
|
||||
continue
|
||||
try:
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
continue
|
||||
successful = True
|
||||
rows = payload.get("skills", []) if isinstance(payload, dict) else []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
source = row.get("source")
|
||||
skill_id = row.get("skillId")
|
||||
values = row.get("weeklyInstalls")
|
||||
if (
|
||||
isinstance(source, str)
|
||||
and isinstance(skill_id, str)
|
||||
and isinstance(values, list)
|
||||
):
|
||||
clean = [
|
||||
value
|
||||
for value in values
|
||||
if isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||
]
|
||||
if len(clean) >= 2:
|
||||
history[(source, skill_id)] = clean
|
||||
|
||||
if successful:
|
||||
_weekly_cache = history
|
||||
_weekly_cache_expires_at = now + _WEEKLY_CACHE_TTL_SECONDS
|
||||
return history
|
||||
|
||||
|
||||
def _valid_skill_refs(skill_ids: list[str]) -> list[tuple[str, str]]:
|
||||
refs: list[tuple[str, str]] = []
|
||||
for value in skill_ids[:20]:
|
||||
if not isinstance(value, str) or "/" not in value:
|
||||
continue
|
||||
source, skill_id = value.rsplit("/", 1)
|
||||
ref = (source, skill_id)
|
||||
if (
|
||||
_SOURCE_RE.fullmatch(source)
|
||||
and _valid_skill_id(skill_id)
|
||||
and ref not in refs
|
||||
):
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
async def _load_skill_page_trends(
|
||||
client: httpx.AsyncClient,
|
||||
refs: list[tuple[str, str]],
|
||||
) -> dict[tuple[str, str], list[int]]:
|
||||
semaphore = asyncio.Semaphore(6)
|
||||
|
||||
async def fetch(ref: tuple[str, str]) -> tuple[tuple[str, str], list[int]]:
|
||||
source, skill_id = ref
|
||||
try:
|
||||
async with semaphore:
|
||||
response = await client.get(
|
||||
f"{_SKILL_PAGE_BASE_URL}/{source}/{skill_id}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
return ref, []
|
||||
|
||||
match = _TREND_VALUES_RE.search(response.text)
|
||||
if match is None:
|
||||
return ref, []
|
||||
values = [
|
||||
int(value)
|
||||
for value in match.group(1).split(",")
|
||||
if value.strip()
|
||||
]
|
||||
return ref, values if len(values) >= 2 else []
|
||||
|
||||
return dict(await asyncio.gather(*(fetch(ref) for ref in refs)))
|
||||
|
||||
|
||||
def _valid_skill_id(value: str) -> bool:
|
||||
return len(value) <= 64 and _SKILL_RE.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _safe_output_tail(output: bytes | None) -> str:
|
||||
if not output:
|
||||
return ""
|
||||
text = _ANSI_RE.sub("", output.decode("utf-8", errors="replace"))
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
return " · ".join(lines[-3:])[-600:]
|
||||
@ -87,7 +87,20 @@ from nanobot.webui.sidebar_state import (
|
||||
read_webui_sidebar_state,
|
||||
write_webui_sidebar_state,
|
||||
)
|
||||
from nanobot.webui.skills_api import webui_skill_detail_payload, webui_skills_payload
|
||||
from nanobot.webui.skills_api import (
|
||||
SkillManagementError,
|
||||
delete_webui_skill,
|
||||
set_webui_skill_enabled,
|
||||
webui_skill_detail_payload,
|
||||
webui_skills_payload,
|
||||
)
|
||||
from nanobot.webui.skills_marketplace import (
|
||||
SkillsMarketplaceError,
|
||||
install_marketplace_skill,
|
||||
marketplace_skill_trends,
|
||||
search_marketplace_skills,
|
||||
trending_marketplace_skills,
|
||||
)
|
||||
from nanobot.webui.thread_disk import delete_webui_thread
|
||||
from nanobot.webui.transcript import build_webui_thread_response
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
@ -171,6 +184,7 @@ class GatewayHTTPHandler:
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
log: Any = logger,
|
||||
) -> None:
|
||||
self.config = config
|
||||
@ -183,7 +197,8 @@ class GatewayHTTPHandler:
|
||||
self.ingress = ingress
|
||||
self.workspaces = workspaces
|
||||
self.skills_workspace_path = skills_workspace_path
|
||||
self.disabled_skills = disabled_skills or set()
|
||||
self.disabled_skills = disabled_skills if disabled_skills is not None else set()
|
||||
self.skill_state_action = skill_state_action
|
||||
self.cron_service = cron_service
|
||||
self.local_trigger_store = local_trigger_store
|
||||
self.cron_pending_job_ids = cron_pending_job_ids
|
||||
@ -795,6 +810,18 @@ class GatewayHTTPHandler:
|
||||
return self._handle_commands(request)
|
||||
if got == "/api/workspaces":
|
||||
return self._handle_workspaces(connection, request)
|
||||
if got == "/api/webui/skills/search":
|
||||
return await self._handle_webui_skills_search(request)
|
||||
if got == "/api/webui/skills/trending":
|
||||
return await self._handle_webui_skills_trending(request)
|
||||
if got == "/api/webui/skills/trends":
|
||||
return await self._handle_webui_skill_trends(request)
|
||||
if got == "/api/webui/skills/install":
|
||||
return await self._handle_webui_skill_install(connection, request)
|
||||
if got == "/api/webui/skills/update":
|
||||
return self._handle_webui_skill_update(request)
|
||||
if got == "/api/webui/skills/delete":
|
||||
return self._handle_webui_skill_delete(connection, request)
|
||||
if got == "/api/webui/skills":
|
||||
return self._handle_webui_skills(request)
|
||||
m = re.match(r"^/api/webui/skills/([^/]+)$", got)
|
||||
@ -830,6 +857,142 @@ class GatewayHTTPHandler:
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_webui_skills_search(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _query_first(_parse_query(request.path), "q") or ""
|
||||
try:
|
||||
payload = await search_marketplace_skills(query, self.skills_workspace_path)
|
||||
except SkillsMarketplaceError as exc:
|
||||
return _http_error(exc.status, exc.message)
|
||||
except Exception:
|
||||
self._log.exception("skills.sh search failed")
|
||||
return _http_error(500, "skills.sh search failed")
|
||||
return _http_json_response(payload)
|
||||
|
||||
async def _handle_webui_skills_trending(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
try:
|
||||
payload = await trending_marketplace_skills(self.skills_workspace_path)
|
||||
except SkillsMarketplaceError as exc:
|
||||
return _http_error(exc.status, exc.message)
|
||||
except Exception:
|
||||
self._log.exception("skills.sh trending lookup failed")
|
||||
return _http_error(500, "skills.sh trending lookup failed")
|
||||
return _http_json_response(payload)
|
||||
|
||||
async def _handle_webui_skill_trends(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
skill_ids = _parse_query(request.path).get("id", [])
|
||||
try:
|
||||
payload = await marketplace_skill_trends(skill_ids)
|
||||
except Exception:
|
||||
self._log.exception("skills.sh trend history lookup failed")
|
||||
return _http_error(500, "skills.sh trend history lookup failed")
|
||||
return _http_json_response(payload)
|
||||
|
||||
async def _handle_webui_skill_install(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if not self._allow_webui_package_install(connection, request):
|
||||
return _http_error(403, "remote skill installation is disabled")
|
||||
|
||||
query = _parse_query(request.path)
|
||||
source = _query_first(query, "source") or ""
|
||||
skill_id = _query_first(query, "skill") or ""
|
||||
try:
|
||||
action = await install_marketplace_skill(
|
||||
source,
|
||||
skill_id,
|
||||
self.skills_workspace_path,
|
||||
)
|
||||
except SkillsMarketplaceError as exc:
|
||||
return _http_error(exc.status, exc.message)
|
||||
except Exception:
|
||||
self._log.exception("skill installation failed")
|
||||
return _http_error(500, "skill installation failed")
|
||||
return _http_json_response({
|
||||
**webui_skills_payload(
|
||||
self.skills_workspace_path,
|
||||
disabled_skills=self.disabled_skills,
|
||||
),
|
||||
"last_action": action,
|
||||
})
|
||||
|
||||
def _allow_webui_package_install(self, connection: Any, request: WsRequest) -> bool:
|
||||
if _is_local_browser_request(connection, request.headers):
|
||||
return True
|
||||
try:
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
return bool(load_config().tools.webui_allow_remote_package_install)
|
||||
except Exception:
|
||||
self._log.exception("failed to load remote package install policy")
|
||||
return False
|
||||
|
||||
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
name = _query_first(query, "name") or ""
|
||||
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
||||
if raw_enabled not in {"true", "false"}:
|
||||
return _http_error(400, "enabled must be true or false")
|
||||
try:
|
||||
action = set_webui_skill_enabled(
|
||||
self.skills_workspace_path,
|
||||
name,
|
||||
enabled=raw_enabled == "true",
|
||||
disabled_skills=self.disabled_skills,
|
||||
)
|
||||
except SkillManagementError as exc:
|
||||
return _http_error(exc.status, exc.message)
|
||||
self._apply_skill_state()
|
||||
return _http_json_response({
|
||||
**webui_skills_payload(
|
||||
self.skills_workspace_path,
|
||||
disabled_skills=self.disabled_skills,
|
||||
),
|
||||
"last_action": action,
|
||||
})
|
||||
|
||||
def _handle_webui_skill_delete(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if not self._allow_webui_package_install(connection, request):
|
||||
return _http_error(403, "remote skill deletion is disabled")
|
||||
name = _query_first(_parse_query(request.path), "name") or ""
|
||||
try:
|
||||
action = delete_webui_skill(
|
||||
self.skills_workspace_path,
|
||||
name,
|
||||
disabled_skills=self.disabled_skills,
|
||||
)
|
||||
except SkillManagementError as exc:
|
||||
return _http_error(exc.status, exc.message)
|
||||
self._apply_skill_state()
|
||||
return _http_json_response({
|
||||
**webui_skills_payload(
|
||||
self.skills_workspace_path,
|
||||
disabled_skills=self.disabled_skills,
|
||||
),
|
||||
"last_action": action,
|
||||
})
|
||||
|
||||
def _apply_skill_state(self) -> None:
|
||||
if self.skill_state_action is not None:
|
||||
self.skill_state_action(set(self.disabled_skills))
|
||||
|
||||
def _handle_webui_skill_detail(self, request: WsRequest, raw_name: str) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
|
||||
@ -388,6 +388,35 @@ class TestBuildMessages:
|
||||
assert "user-only runtime context" not in messages[-1]["content"]
|
||||
assert "_meta" not in messages[-1]
|
||||
|
||||
def test_explicit_skill_reference_loads_full_instructions_for_this_turn(self, tmp_path):
|
||||
skill_dir = tmp_path / "skills" / "review"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: review\n"
|
||||
"description: Review changes.\n"
|
||||
"---\n\n"
|
||||
"# Review workflow\n\nFollow the unique review checklist.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
builder = _builder(tmp_path)
|
||||
|
||||
messages = builder.build_messages([], "Please $review this patch and use $review carefully.")
|
||||
|
||||
system_prompt = messages[0]["content"]
|
||||
assert "# Active Skills" in system_prompt
|
||||
assert "### Skill: review" in system_prompt
|
||||
assert "Follow the unique review checklist." in system_prompt
|
||||
assert system_prompt.count("### Skill: review") == 1
|
||||
assert messages[-1]["content"] == (
|
||||
"Please $review this patch and use $review carefully."
|
||||
)
|
||||
|
||||
def test_unknown_skill_reference_does_not_change_active_skills(self, tmp_path):
|
||||
messages = _builder(tmp_path).build_messages([], "Keep the shell literal $HOME.")
|
||||
|
||||
assert "# Active Skills" not in messages[0]["content"]
|
||||
|
||||
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
messages = builder.build_messages([], "hello", channel="cli")
|
||||
|
||||
@ -351,6 +351,37 @@ def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None
|
||||
assert "beta" in always
|
||||
|
||||
|
||||
def test_explicit_skill_references_resolve_available_enabled_names_in_order(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
_write_skill(skills_root, "alpha", body="# Alpha")
|
||||
_write_skill(skills_root, "beta", body="# Beta")
|
||||
_write_skill(
|
||||
skills_root,
|
||||
"blocked",
|
||||
metadata_json={"requires": {"env": ["MISSING_SKILL_TEST_ENV"]}},
|
||||
body="# Blocked",
|
||||
)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
monkeypatch.delenv("MISSING_SKILL_TEST_ENV", raising=False)
|
||||
loader = SkillsLoader(
|
||||
workspace,
|
||||
builtin_skills_dir=builtin,
|
||||
disabled_skills={"beta"},
|
||||
)
|
||||
|
||||
invoked = loader.get_explicitly_invoked_skills(
|
||||
"Use $alpha, then $unknown, $alpha again, $beta, and $blocked."
|
||||
)
|
||||
|
||||
assert invoked == ["alpha"]
|
||||
|
||||
|
||||
# -- multiline description tests (YAML folded > and literal |) -----------------
|
||||
|
||||
|
||||
|
||||
128
tests/webui/test_skills_api.py
Normal file
128
tests/webui/test_skills_api.py
Normal file
@ -0,0 +1,128 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.webui.skills_api import (
|
||||
SkillManagementError,
|
||||
delete_webui_skill,
|
||||
set_webui_skill_enabled,
|
||||
webui_skill_detail_payload,
|
||||
webui_skills_payload,
|
||||
)
|
||||
|
||||
|
||||
def _write_skill(workspace: Path, name: str, *, metadata: str = "") -> Path:
|
||||
directory = workspace / "skills" / name
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {name} description.\n{metadata}---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
def _config(*disabled: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
agents=SimpleNamespace(
|
||||
defaults=SimpleNamespace(disabled_skills=list(disabled)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_skills_remain_visible_and_loadable(tmp_path: Path) -> None:
|
||||
_write_skill(tmp_path, "custom-skill")
|
||||
|
||||
payload = webui_skills_payload(tmp_path, disabled_skills={"custom-skill"})
|
||||
skill = next(item for item in payload["skills"] if item["name"] == "custom-skill")
|
||||
|
||||
assert skill["enabled"] is False
|
||||
assert skill["deletable"] is True
|
||||
detail = webui_skill_detail_payload(
|
||||
tmp_path,
|
||||
"custom-skill",
|
||||
disabled_skills={"custom-skill"},
|
||||
)
|
||||
assert detail is not None
|
||||
assert detail["enabled"] is False
|
||||
assert "custom-skill description" in detail["raw_markdown"]
|
||||
|
||||
|
||||
def test_skill_detail_exposes_copyable_install_commands(tmp_path: Path) -> None:
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"custom-skill",
|
||||
metadata=(
|
||||
'metadata: {"nanobot":{"requires":{"bins":["demo"]},'
|
||||
'"install":[{"id":"brew","kind":"brew","formula":"acme/demo",'
|
||||
'"label":"Install demo"}]}}\n'
|
||||
),
|
||||
)
|
||||
|
||||
detail = webui_skill_detail_payload(tmp_path, "custom-skill")
|
||||
|
||||
assert detail is not None
|
||||
assert detail["install_options"] == [
|
||||
{
|
||||
"id": "brew",
|
||||
"kind": "brew",
|
||||
"label": "Install demo",
|
||||
"command": "brew install acme/demo",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_set_webui_skill_enabled_persists_and_updates_runtime(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_write_skill(tmp_path, "custom-skill")
|
||||
config = _config()
|
||||
saved: list[object] = []
|
||||
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
|
||||
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append)
|
||||
disabled: set[str] = set()
|
||||
|
||||
action = set_webui_skill_enabled(
|
||||
tmp_path,
|
||||
"custom-skill",
|
||||
enabled=False,
|
||||
disabled_skills=disabled,
|
||||
)
|
||||
|
||||
assert action == {
|
||||
"name": "custom-skill",
|
||||
"enabled": False,
|
||||
"deleted": False,
|
||||
}
|
||||
assert config.agents.defaults.disabled_skills == ["custom-skill"]
|
||||
assert disabled == {"custom-skill"}
|
||||
assert saved == [config]
|
||||
|
||||
|
||||
def test_delete_webui_skill_only_deletes_workspace_skills(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
directory = _write_skill(tmp_path, "custom-skill")
|
||||
config = _config("custom-skill")
|
||||
saved: list[object] = []
|
||||
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
|
||||
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append)
|
||||
disabled = {"custom-skill"}
|
||||
|
||||
action = delete_webui_skill(
|
||||
tmp_path,
|
||||
"custom-skill",
|
||||
disabled_skills=disabled,
|
||||
)
|
||||
|
||||
assert action["deleted"] is True
|
||||
assert not directory.exists()
|
||||
assert disabled == set()
|
||||
assert config.agents.defaults.disabled_skills == []
|
||||
assert saved == [config]
|
||||
|
||||
with pytest.raises(SkillManagementError) as exc_info:
|
||||
delete_webui_skill(tmp_path, "cron", disabled_skills=disabled)
|
||||
assert exc_info.value.status == 403
|
||||
300
tests/webui/test_skills_marketplace.py
Normal file
300
tests/webui/test_skills_marketplace.py
Normal file
@ -0,0 +1,300 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.webui.skills_marketplace import (
|
||||
SkillsMarketplaceError,
|
||||
install_marketplace_skill,
|
||||
marketplace_skill_trends,
|
||||
search_marketplace_skills,
|
||||
trending_marketplace_skills,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_marketplace_skills_filters_and_marks_installed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
skill_dir = tmp_path / "skills" / "react-testing"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: react-testing\n---\n", encoding="utf-8")
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"skills": [
|
||||
{
|
||||
"name": "React Testing",
|
||||
"skillId": "react-testing",
|
||||
"source": "acme/agent-skills",
|
||||
"installs": 42,
|
||||
},
|
||||
{"skillId": "../escape", "source": "acme/agent-skills"},
|
||||
{"skillId": "valid-name", "source": "not-a-repository"},
|
||||
]
|
||||
}
|
||||
|
||||
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(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.skills_marketplace.skills_install_supported",
|
||||
lambda: True,
|
||||
)
|
||||
payload = await search_marketplace_skills(" react testing ", tmp_path)
|
||||
|
||||
assert seen == {
|
||||
"url": "https://skills.sh/api/search",
|
||||
"params": {"q": "react testing", "limit": 20},
|
||||
}
|
||||
assert payload == {
|
||||
"query": "react testing",
|
||||
"install_supported": True,
|
||||
"skills": [
|
||||
{
|
||||
"id": "acme/agent-skills/react-testing",
|
||||
"skill_id": "react-testing",
|
||||
"name": "React Testing",
|
||||
"source": "acme/agent-skills",
|
||||
"installs": 42,
|
||||
"url": "https://skills.sh/acme/agent-skills/react-testing",
|
||||
"installed": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trending_marketplace_skills_diversifies_sources_and_keeps_rank(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"skills": [
|
||||
{
|
||||
"name": "First",
|
||||
"skillId": "first",
|
||||
"source": "acme/skills",
|
||||
"installs": 50,
|
||||
},
|
||||
{
|
||||
"name": "Second from same source",
|
||||
"skillId": "second",
|
||||
"source": "acme/skills",
|
||||
"installs": 49,
|
||||
},
|
||||
{
|
||||
"name": "Another",
|
||||
"skillId": "another",
|
||||
"source": "other/skills",
|
||||
"installs": 30,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self) -> "FakeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
pass
|
||||
|
||||
async def get(self, url: str) -> FakeResponse:
|
||||
assert url == "https://skills.sh/api/skills/trending/0"
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
|
||||
lambda **_kwargs: FakeClient(),
|
||||
)
|
||||
payload = await trending_marketplace_skills(tmp_path)
|
||||
|
||||
assert payload["period"] == "24h"
|
||||
assert [(skill["name"], skill["rank"]) for skill in payload["skills"]] == [
|
||||
("First", 1),
|
||||
("Another", 3),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marketplace_skill_trends_returns_history_separately(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeResponse:
|
||||
text = r'<script>\"values\":[3,5,8,13]</script>'
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self) -> "FakeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
pass
|
||||
|
||||
async def get(self, url: str) -> FakeResponse:
|
||||
assert url == "https://www.skills.sh/other/skills/second"
|
||||
return FakeResponse()
|
||||
|
||||
async def weekly_installs(_client: object) -> dict[tuple[str, str], list[int]]:
|
||||
return {
|
||||
("acme/skills", "first"): [2, 4, 3, 8],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
|
||||
lambda **_kwargs: FakeClient(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.skills_marketplace._load_weekly_installs",
|
||||
weekly_installs,
|
||||
)
|
||||
|
||||
assert await marketplace_skill_trends([
|
||||
"acme/skills/first",
|
||||
"other/skills/second",
|
||||
"invalid",
|
||||
]) == {
|
||||
"trends": {
|
||||
"acme/skills/first": [2, 4, 3, 8],
|
||||
"other/skills/second": [3, 5, 8, 13],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_marketplace_skills_returns_safe_upstream_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FailingClient:
|
||||
async def __aenter__(self) -> "FailingClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
pass
|
||||
|
||||
async def get(self, *_args: object, **_kwargs: object) -> None:
|
||||
raise httpx.ConnectError("private network detail")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
|
||||
lambda **_kwargs: FailingClient(),
|
||||
)
|
||||
|
||||
with pytest.raises(SkillsMarketplaceError) as exc_info:
|
||||
await search_marketplace_skills("react", tmp_path)
|
||||
|
||||
assert exc_info.value.status == 502
|
||||
assert exc_info.value.message == "skills.sh search is temporarily unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_marketplace_skill_uses_official_cli_and_workspace(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
class FakeProcess:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self) -> tuple[bytes, None]:
|
||||
skill_dir = tmp_path / "skills" / "react-testing"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: react-testing\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return b"installed", None
|
||||
|
||||
def kill(self) -> None:
|
||||
raise AssertionError("successful install must not be killed")
|
||||
|
||||
async def create_subprocess_exec(*command: str, **kwargs: object) -> FakeProcess:
|
||||
seen.update(command=command, **kwargs)
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.skills_marketplace.shutil.which",
|
||||
lambda executable: "/usr/local/bin/npx" if executable == "npx" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
result = await install_marketplace_skill(
|
||||
"acme/agent-skills",
|
||||
"react-testing",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"installed": True,
|
||||
"already_installed": False,
|
||||
"name": "react-testing",
|
||||
}
|
||||
assert seen["command"] == (
|
||||
"/usr/local/bin/npx",
|
||||
"--yes",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"acme/agent-skills",
|
||||
"--skill",
|
||||
"react-testing",
|
||||
"--agent",
|
||||
"openclaw",
|
||||
"--copy",
|
||||
"--yes",
|
||||
)
|
||||
assert seen["cwd"] == str(tmp_path.resolve())
|
||||
assert seen["env"]["DISABLE_TELEMETRY"] == "1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_marketplace_skill_is_idempotent(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
skill_dir = tmp_path / "skills" / "already-here"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: already-here\n---\n", encoding="utf-8")
|
||||
launch = pytest.fail
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
|
||||
launch,
|
||||
)
|
||||
|
||||
result = await install_marketplace_skill("acme/agent-skills", "already-here", tmp_path)
|
||||
|
||||
assert result == {
|
||||
"installed": True,
|
||||
"already_installed": True,
|
||||
"name": "already-here",
|
||||
}
|
||||
@ -1,25 +1,89 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
CircleAlert,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
PowerOff,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
|
||||
import { fetchSkillDetail } from "@/lib/api";
|
||||
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
|
||||
import { deleteSkill, fetchSkillDetail, updateSkillEnabled } from "@/lib/api";
|
||||
import { notifySkillsChanged } from "@/lib/skill-events";
|
||||
import type { SkillDetail, SkillSummary } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
const availableCount = skills.filter((skill) => skill.available).length;
|
||||
const availableCount = skills.filter(
|
||||
(skill) => skill.enabled !== false && skill.available,
|
||||
).length;
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
|
||||
const [view, setView] = useState<"installed" | "discover">("installed");
|
||||
const [installedQuery, setInstalledQuery] = useState("");
|
||||
const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">(
|
||||
"all",
|
||||
);
|
||||
const normalizedQuery = installedQuery.trim().toLowerCase();
|
||||
const filteredSkills = skills.filter((skill) => {
|
||||
const enabled = skill.enabled !== false;
|
||||
if (installedFilter === "enabled" && !enabled) return false;
|
||||
if (installedFilter === "disabled" && enabled) return false;
|
||||
return (
|
||||
!normalizedQuery
|
||||
|| skill.name.toLowerCase().includes(normalizedQuery)
|
||||
|| skill.description.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
});
|
||||
const groupedSkills = [
|
||||
{
|
||||
key: "workspace",
|
||||
label: t("settings.skills.customGroup", { defaultValue: "Custom" }),
|
||||
skills: filteredSkills.filter((skill) => skill.source === "workspace"),
|
||||
},
|
||||
{
|
||||
key: "builtin",
|
||||
label: t("settings.skills.builtinGroup", { defaultValue: "Built-in" }),
|
||||
skills: filteredSkills.filter((skill) => skill.source === "builtin"),
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
label: t("settings.skills.otherGroup", { defaultValue: "Other" }),
|
||||
skills: filteredSkills.filter(
|
||||
(skill) => skill.source !== "workspace" && skill.source !== "builtin",
|
||||
),
|
||||
},
|
||||
].filter((group) => group.skills.length);
|
||||
const disabledCount = skills.filter((skill) => skill.enabled === false).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.description", {
|
||||
defaultValue: "Review the instruction skills this agent can load during a conversation.",
|
||||
defaultValue:
|
||||
"Review installed skills or discover new capabilities from the skills.sh catalog.",
|
||||
})}
|
||||
</p>
|
||||
<span className="text-[12px] font-medium text-muted-foreground">
|
||||
@ -31,31 +95,122 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4">
|
||||
<div className="flex items-center justify-between border-b border-border/45 pb-3">
|
||||
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
|
||||
</h2>
|
||||
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
{skills.length}
|
||||
</span>
|
||||
</div>
|
||||
{skills.length ? (
|
||||
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
|
||||
{skills.map((skill) => (
|
||||
<SkillCatalogRow
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
skill={skill}
|
||||
onSelect={setSelectedSkill}
|
||||
<div
|
||||
className="inline-flex rounded-[12px] bg-muted/65 p-1"
|
||||
role="tablist"
|
||||
aria-label={t("settings.skills.views", { defaultValue: "Skills views" })}
|
||||
>
|
||||
{(["installed", "discover"] as const).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === item}
|
||||
onClick={() => setView(item)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-[9px] px-3.5 py-1.5 text-[13px] font-medium transition-colors",
|
||||
view === item
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{item === "installed"
|
||||
? t("settings.skills.installedTab", { defaultValue: "Installed" })
|
||||
: (
|
||||
<>
|
||||
<VercelMark />
|
||||
{t("settings.skills.discoverTab", { defaultValue: "Discover" })}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === "installed" ? (
|
||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||
<div className="flex flex-col gap-3 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-[320px]">
|
||||
<Search
|
||||
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={installedQuery}
|
||||
onChange={(event) => setInstalledQuery(event.target.value)}
|
||||
placeholder={t("settings.skills.searchInstalled", {
|
||||
defaultValue: "Search installed skills",
|
||||
})}
|
||||
aria-label={t("settings.skills.searchInstalled", {
|
||||
defaultValue: "Search installed skills",
|
||||
})}
|
||||
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-[10px] bg-muted/65 p-1">
|
||||
{([
|
||||
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
|
||||
[
|
||||
"enabled",
|
||||
t("settings.skills.filterEnabled", { defaultValue: "Enabled" }),
|
||||
skills.length - disabledCount,
|
||||
],
|
||||
[
|
||||
"disabled",
|
||||
t("settings.skills.filterDisabled", { defaultValue: "Disabled" }),
|
||||
disabledCount,
|
||||
],
|
||||
] as const).map(([filter, label, count]) => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
onClick={() => setInstalledFilter(filter)}
|
||||
className={cn(
|
||||
"rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
|
||||
installedFilter === filter
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
||||
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{groupedSkills.length ? (
|
||||
<div className="pb-2">
|
||||
{groupedSkills.map((group) => (
|
||||
<section key={group.key}>
|
||||
<div className="flex items-center gap-2 bg-muted/20 px-5 py-2.5">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{group.label}
|
||||
</h2>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground/60">
|
||||
{group.skills.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 px-3 sm:px-4">
|
||||
{group.skills.map((skill) => (
|
||||
<SkillCatalogRow
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
skill={skill}
|
||||
onSelect={setSelectedSkill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
||||
{t("settings.skills.noMatching", {
|
||||
defaultValue: "No matching skills.",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<SkillsMarketplace installedSkills={skills} />
|
||||
)}
|
||||
|
||||
<SkillDetailSheet
|
||||
skill={selectedSkill}
|
||||
@ -68,6 +223,14 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VercelMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" className="h-3 w-3" aria-hidden>
|
||||
<path d="M8 1 16 15H0L8 1Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCatalogRow({
|
||||
skill,
|
||||
onSelect,
|
||||
@ -76,11 +239,13 @@ function SkillCatalogRow({
|
||||
onSelect: (skill: SkillSummary) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sourceLabel = skillSourceLabel(skill.source, t);
|
||||
const StatusIcon = skill.available ? Check : CircleAlert;
|
||||
const statusLabel = skill.available
|
||||
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
|
||||
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
|
||||
const enabled = skill.enabled !== false;
|
||||
const StatusIcon = !enabled ? PowerOff : skill.available ? Check : CircleAlert;
|
||||
const statusLabel = !enabled
|
||||
? t("settings.skills.statusDisabled", { defaultValue: "Disabled" })
|
||||
: skill.available
|
||||
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
|
||||
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -91,42 +256,28 @@ function SkillCatalogRow({
|
||||
})}
|
||||
onClick={() => onSelect(skill)}
|
||||
className={cn(
|
||||
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
|
||||
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left transition-colors",
|
||||
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
!skill.available && "opacity-65",
|
||||
!enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
|
||||
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
|
||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 line-clamp-1 text-[12px] leading-5 text-muted-foreground">
|
||||
{skill.description}
|
||||
</p>
|
||||
{!skill.available && skill.unavailable_reason ? (
|
||||
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
|
||||
{t("settings.skills.unavailableReason", {
|
||||
reason: skill.unavailable_reason,
|
||||
defaultValue: "Missing: {{reason}}",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
|
||||
title={enabled && !skill.available ? skill.unavailable_reason : undefined}
|
||||
className={cn(
|
||||
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
|
||||
skill.available
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
"hidden w-[92px] shrink-0 items-center justify-end gap-1 text-[11px] font-medium sm:inline-flex",
|
||||
!enabled
|
||||
? "text-muted-foreground"
|
||||
: skill.available
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
|
||||
@ -150,6 +301,10 @@ function SkillDetailSheet({
|
||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !skill) return;
|
||||
@ -157,6 +312,8 @@ function SkillDetailSheet({
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
setActionError("");
|
||||
setDeleteOpen(false);
|
||||
fetchSkillDetail(token, skill.name)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setDetail(payload);
|
||||
@ -170,90 +327,228 @@ function SkillDetailSheet({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, skill, token]);
|
||||
}, [open, refreshKey, skill, token]);
|
||||
|
||||
if (!skill) return null;
|
||||
|
||||
const activeSkill = detail ?? skill;
|
||||
const enabled = activeSkill.enabled !== false;
|
||||
const deletable = activeSkill.deletable ?? activeSkill.source === "workspace";
|
||||
const sourceLabel = skillSourceLabel(activeSkill.source, t);
|
||||
const statusLabel = activeSkill.available
|
||||
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
|
||||
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
|
||||
const statusLabel = !enabled
|
||||
? t("settings.skills.statusDisabled", { defaultValue: "Disabled" })
|
||||
: activeSkill.available
|
||||
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
|
||||
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
|
||||
|
||||
const toggleEnabled = async () => {
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await updateSkillEnabled(token, activeSkill.name, !enabled);
|
||||
notifySkillsChanged(payload);
|
||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||
if (updated) {
|
||||
setDetail((current) => current ? { ...current, ...updated } : current);
|
||||
}
|
||||
} catch (reason) {
|
||||
setActionError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("settings.skills.updateFailed", {
|
||||
defaultValue: "Could not update this skill.",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSkill = async () => {
|
||||
setDeleteOpen(false);
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await deleteSkill(token, activeSkill.name);
|
||||
notifySkillsChanged(payload);
|
||||
onOpenChange(false);
|
||||
} catch (reason) {
|
||||
setActionError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("settings.skills.deleteFailed", {
|
||||
defaultValue: "Could not delete this skill.",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
|
||||
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate text-[20px] font-semibold">
|
||||
{activeSkill.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("settings.skills.detailDescription", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: "Details for {{name}}.",
|
||||
})}
|
||||
</SheetDescription>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<Pill>{sourceLabel}</Pill>
|
||||
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
|
||||
</div>
|
||||
) : loadFailed ? (
|
||||
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
|
||||
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-7 space-y-6">
|
||||
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
|
||||
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
|
||||
</DetailSection>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MetaItem
|
||||
label={t("settings.skills.source", { defaultValue: "Source" })}
|
||||
value={sourceLabel}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("settings.skills.status", { defaultValue: "Status" })}
|
||||
value={statusLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!activeSkill.available && activeSkill.unavailable_reason ? (
|
||||
<DetailSection
|
||||
title={t("settings.skills.unavailableReasonLabel", {
|
||||
defaultValue: "Unavailable reason",
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="truncate text-[20px] font-semibold">
|
||||
{activeSkill.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("settings.skills.detailDescription", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: "Details for {{name}}.",
|
||||
})}
|
||||
>
|
||||
<p className="text-[13px] leading-5 text-destructive/85">
|
||||
{activeSkill.unavailable_reason}
|
||||
</p>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{detail ? <RequirementsSection detail={detail} /> : null}
|
||||
|
||||
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
|
||||
</SheetDescription>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<Pill>{sourceLabel}</Pill>
|
||||
<Pill
|
||||
tone={
|
||||
!enabled
|
||||
? "muted"
|
||||
: activeSkill.available
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{statusLabel}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="mt-3 text-[13px] leading-5 text-muted-foreground">
|
||||
{activeSkill.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
|
||||
</div>
|
||||
) : loadFailed ? (
|
||||
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
|
||||
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-5">
|
||||
<div className="flex items-center justify-between gap-4 border-y border-border/45 px-1 py-3.5">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-foreground">
|
||||
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.enabledDescription", {
|
||||
defaultValue:
|
||||
"Allow the agent to load this skill when its requirements are ready.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={t("settings.skills.toggleEnabled", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: enabled ? "Disable {{name}}" : "Enable {{name}}",
|
||||
})}
|
||||
disabled={actionBusy}
|
||||
onClick={() => void toggleEnabled()}
|
||||
className={cn(
|
||||
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-wait disabled:opacity-60",
|
||||
enabled ? "bg-foreground" : "bg-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-background shadow-sm transition-transform",
|
||||
enabled ? "translate-x-5" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail && enabled ? (
|
||||
<RequirementsSection
|
||||
detail={detail}
|
||||
onRefresh={() => setRefreshKey((value) => value + 1)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
|
||||
|
||||
{deletable ? (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border/45 pt-5">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-foreground">
|
||||
{t("settings.skills.deleteTitle", { defaultValue: "Delete skill" })}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.deleteDescription", {
|
||||
defaultValue: "Remove this skill from the current workspace.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="shrink-0 rounded-full"
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.skills.deleteAction", { defaultValue: "Delete" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent className="rounded-[20px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.skills.deleteConfirmTitle", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: "Delete {{name}}?",
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.skills.deleteConfirmDescription", {
|
||||
defaultValue:
|
||||
"This removes the skill files from the current workspace. This action cannot be undone.",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => void removeSkill()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("settings.skills.deleteConfirmAction", { defaultValue: "Delete skill" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -267,8 +562,13 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
|
||||
|
||||
return (
|
||||
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
|
||||
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
|
||||
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
|
||||
<span>
|
||||
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
|
||||
</span>
|
||||
<code className="font-mono text-[10px] font-normal text-muted-foreground">
|
||||
SKILL.md
|
||||
</code>
|
||||
</summary>
|
||||
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
|
||||
<pre
|
||||
@ -287,100 +587,131 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementsSection({ detail }: { detail: SkillDetail }) {
|
||||
function RequirementsSection({
|
||||
detail,
|
||||
onRefresh,
|
||||
}: {
|
||||
detail: SkillDetail;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { bins, env, missing_bins, missing_env } = detail.requirements;
|
||||
const hasRequirements = bins.length > 0 || env.length > 0;
|
||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||
const { missing_bins, missing_env } = detail.requirements;
|
||||
const hasMissing = missing_bins.length > 0 || missing_env.length > 0;
|
||||
|
||||
if (!hasMissing) return null;
|
||||
|
||||
const installOptions = detail.install_options ?? [];
|
||||
const copyCommand = async (command: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopiedCommand(command);
|
||||
} catch {
|
||||
setCopiedCommand(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
|
||||
{hasRequirements ? (
|
||||
<div className="space-y-3">
|
||||
{missing_bins.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
|
||||
items={missing_bins}
|
||||
tone="danger"
|
||||
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{missing_env.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
|
||||
items={missing_env}
|
||||
tone="danger"
|
||||
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{bins.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.commands", { defaultValue: "Commands" })}
|
||||
items={bins}
|
||||
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{env.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
|
||||
items={env}
|
||||
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
<section className="rounded-[18px] border border-amber-500/20 bg-amber-500/[0.06] p-4">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<CircleAlert
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[13px] font-semibold text-foreground">
|
||||
{t("settings.skills.setupRequired", { defaultValue: "Setup required" })}
|
||||
</h3>
|
||||
<p className="mt-1 text-[12px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.setupDescription", {
|
||||
defaultValue:
|
||||
"Install the missing dependency on the machine running nanobot, then check again.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
|
||||
{children}
|
||||
<div className="mt-3 space-y-2">
|
||||
{installOptions.map((option) => (
|
||||
<div
|
||||
key={`${option.id}:${option.command}`}
|
||||
className="flex min-w-0 items-center gap-2 rounded-[12px] bg-background/80 px-3 py-2"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground/80">
|
||||
{option.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("settings.skills.copySetupCommand", {
|
||||
defaultValue: "Copy setup command",
|
||||
})}
|
||||
title={option.label}
|
||||
onClick={() => void copyCommand(option.command)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{copiedCommand === option.command ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!installOptions.length && missing_bins.length ? (
|
||||
<SetupRequirement
|
||||
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
|
||||
label={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
|
||||
items={missing_bins}
|
||||
/>
|
||||
) : null}
|
||||
{missing_env.length ? (
|
||||
<SetupRequirement
|
||||
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
|
||||
label={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
|
||||
items={missing_env}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="mt-3 h-8 rounded-full bg-background/60 px-3 text-[11px]"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.skills.checkAgain", { defaultValue: "Check again" })}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementLine({
|
||||
title,
|
||||
function SetupRequirement({
|
||||
label,
|
||||
items,
|
||||
icon,
|
||||
tone = "muted",
|
||||
}: {
|
||||
title: string;
|
||||
label: string;
|
||||
items: string[];
|
||||
icon: ReactNode;
|
||||
tone?: "muted" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-[12px]",
|
||||
tone === "danger" ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 py-1 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<Pill key={item}>{item}</Pill>
|
||||
))}
|
||||
</div>
|
||||
{label}
|
||||
</span>
|
||||
{items.map((item) => (
|
||||
<code
|
||||
key={item}
|
||||
className="rounded-full bg-background/80 px-2 py-0.5 font-mono text-[10px] text-foreground/70"
|
||||
>
|
||||
{item}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -390,7 +721,7 @@ function Pill({
|
||||
tone = "muted",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "muted" | "success";
|
||||
tone?: "muted" | "success" | "warning";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
@ -398,7 +729,9 @@ function Pill({
|
||||
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
tone === "success"
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
: tone === "warning"
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
520
webui/src/components/settings/SkillsMarketplace.tsx
Normal file
520
webui/src/components/settings/SkillsMarketplace.tsx
Normal file
@ -0,0 +1,520 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
fetchMarketplaceSkillTrends,
|
||||
fetchTrendingMarketplaceSkills,
|
||||
installMarketplaceSkill,
|
||||
searchMarketplaceSkills,
|
||||
} from "@/lib/api";
|
||||
import { notifySkillsChanged } from "@/lib/skill-events";
|
||||
import type { MarketplaceSkillSummary, SkillSummary } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillSummary[] }) {
|
||||
const { token } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||
const [trending, setTrending] = useState<MarketplaceSkillSummary[]>([]);
|
||||
const [trends, setTrends] = useState<Record<string, number[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [trendingLoading, setTrendingLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [installSupported, setInstallSupported] = useState<boolean | null>(null);
|
||||
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
|
||||
const [installing, setInstalling] = useState("");
|
||||
const installedNames = useMemo(
|
||||
() => new Set(installedSkills.map((skill) => skill.name)),
|
||||
[installedSkills],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTrendingLoading(true);
|
||||
fetchTrendingMarketplaceSkills(token)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setTrending(payload.skills);
|
||||
setInstallSupported(payload.install_supported);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setTrending([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTrendingLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
const skills = query.trim().length < 2 ? trending : results;
|
||||
const unresolved = skills.filter((skill) => !(skill.id in trends));
|
||||
if (!unresolved.length) return;
|
||||
|
||||
let cancelled = false;
|
||||
fetchMarketplaceSkillTrends(token, unresolved.map((skill) => skill.id))
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
setTrends((current) => ({ ...current, ...payload.trends }));
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, results, token, trending, trends]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = query.trim();
|
||||
if (normalized.length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
searchMarketplaceSkills(token, normalized)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setResults(payload.skills);
|
||||
setInstallSupported(payload.install_supported);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return;
|
||||
setResults([]);
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("settings.skills.marketplaceSearchFailed", {
|
||||
defaultValue: "Could not search skills.sh.",
|
||||
}),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [query, t, token]);
|
||||
|
||||
const install = async (skill: MarketplaceSkillSummary) => {
|
||||
setSelected(null);
|
||||
setInstalling(skill.skill_id);
|
||||
setError("");
|
||||
try {
|
||||
const payload = await installMarketplaceSkill(token, skill.source, skill.skill_id);
|
||||
notifySkillsChanged(payload);
|
||||
setResults((current) =>
|
||||
current.map((item) =>
|
||||
item.id === skill.id ? { ...item, installed: true } : item,
|
||||
),
|
||||
);
|
||||
setTrending((current) =>
|
||||
current.map((item) =>
|
||||
item.id === skill.id ? { ...item, installed: true } : item,
|
||||
),
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("settings.skills.marketplaceInstallFailed", {
|
||||
defaultValue: "Could not install this skill.",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setInstalling("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search
|
||||
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}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("settings.skills.marketplaceSearchPlaceholder", {
|
||||
defaultValue: "Search skills.sh",
|
||||
})}
|
||||
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",
|
||||
})}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{query.trim().length < 2 ? (
|
||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||
<div className="flex items-center justify-between border-b border-border/45 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold">
|
||||
{t("settings.skills.marketplaceTrendingTitle", {
|
||||
defaultValue: "Trending today",
|
||||
})}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||
{t("settings.skills.marketplaceTrendingDescription", {
|
||||
defaultValue:
|
||||
"Most installed across sources in 24h · curves show the 8-week trend",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://skills.sh/trending"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
</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>
|
||||
) : !loading && results.length === 0 && !error ? (
|
||||
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
|
||||
{t("settings.skills.marketplaceEmpty", {
|
||||
query: query.trim(),
|
||||
defaultValue: "No skills found for “{{query}}”.",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||
<MarketplaceSkillList
|
||||
skills={results}
|
||||
installedNames={installedNames}
|
||||
installing={installing}
|
||||
installSupported={installSupported}
|
||||
metric="total"
|
||||
trends={trends}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={selected !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="rounded-[20px]">
|
||||
<AlertDialogHeader>
|
||||
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-[12px] bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
||||
<ShieldAlert className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.skills.marketplaceConfirmTitle", {
|
||||
name: selected?.name ?? "",
|
||||
defaultValue: "Install {{name}}?",
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<span className="block">
|
||||
{t("settings.skills.marketplaceConfirmDescription", {
|
||||
source: selected?.source ?? "",
|
||||
defaultValue:
|
||||
"This third-party skill comes from {{source}} and may include instructions or executable scripts.",
|
||||
})}
|
||||
</span>
|
||||
<code className="block rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
|
||||
{selected?.source}
|
||||
</code>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (selected) void install(selected);
|
||||
}}
|
||||
>
|
||||
{t("settings.skills.marketplaceConfirmInstall", {
|
||||
defaultValue: "Install skill",
|
||||
})}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketplaceSkillList({
|
||||
skills,
|
||||
installedNames,
|
||||
installing,
|
||||
installSupported,
|
||||
metric,
|
||||
trends,
|
||||
onSelect,
|
||||
}: {
|
||||
skills: MarketplaceSkillSummary[];
|
||||
installedNames: Set<string>;
|
||||
installing: string;
|
||||
installSupported: boolean | null;
|
||||
metric: "total" | "24h";
|
||||
trends: Record<string, number[]>;
|
||||
onSelect: (skill: MarketplaceSkillSummary) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="divide-y divide-border/45 px-3 sm:px-4">
|
||||
{skills.map((skill) => (
|
||||
<MarketplaceSkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
installed={skill.installed || installedNames.has(skill.skill_id)}
|
||||
isInstalling={installing === skill.skill_id}
|
||||
installBusy={Boolean(installing)}
|
||||
installSupported={installSupported}
|
||||
metric={metric}
|
||||
trend={trends[skill.id]}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketplaceSkillRow({
|
||||
skill,
|
||||
installed,
|
||||
isInstalling,
|
||||
installBusy,
|
||||
installSupported,
|
||||
metric,
|
||||
trend,
|
||||
onSelect,
|
||||
}: {
|
||||
skill: MarketplaceSkillSummary;
|
||||
installed: boolean;
|
||||
isInstalling: boolean;
|
||||
installBusy: boolean;
|
||||
installSupported: boolean | null;
|
||||
metric: "total" | "24h";
|
||||
trend?: number[];
|
||||
onSelect: (skill: MarketplaceSkillSummary) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3 px-2 py-3.5">
|
||||
{skill.rank ? (
|
||||
<span className="w-7 shrink-0 text-right font-mono text-[12px] tabular-nums text-muted-foreground/65">
|
||||
#{skill.rank}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-[14px] font-semibold text-foreground">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<a
|
||||
href={skill.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("settings.skills.marketplaceOpen", {
|
||||
name: skill.name,
|
||||
defaultValue: "Open {{name}} on skills.sh",
|
||||
})}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[12px] text-muted-foreground">
|
||||
{skill.source}
|
||||
<span className="mx-1.5">·</span>
|
||||
{metric === "24h"
|
||||
? t("settings.skills.marketplaceInstalls24h", {
|
||||
count: skill.installs,
|
||||
formattedCount: skill.installs.toLocaleString(),
|
||||
defaultValue: "{{formattedCount}} installs / 24h",
|
||||
})
|
||||
: t("settings.skills.marketplaceInstalls", {
|
||||
count: skill.installs,
|
||||
formattedCount: skill.installs.toLocaleString(),
|
||||
defaultValue: "{{formattedCount}} installs",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<TrendSparkline values={trend} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={installed ? "secondary" : "default"}
|
||||
disabled={installed || installBusy || installSupported === false}
|
||||
onClick={() => onSelect(skill)}
|
||||
className={cn("min-w-[92px] rounded-full", installed && "text-emerald-700")}
|
||||
title={
|
||||
installSupported === false
|
||||
? t("settings.skills.marketplaceNpxRequired", {
|
||||
defaultValue: "Node.js with npx is required",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : installed ? (
|
||||
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{isInstalling
|
||||
? t("settings.skills.marketplaceInstalling", {
|
||||
defaultValue: "Installing",
|
||||
})
|
||||
: installed
|
||||
? t("settings.skills.marketplaceInstalled", {
|
||||
defaultValue: "Installed",
|
||||
})
|
||||
: t("settings.skills.marketplaceInstall", {
|
||||
defaultValue: "Install",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendSparkline({ values }: { values?: number[] }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (values === undefined) {
|
||||
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
|
||||
}
|
||||
if (values.length < 2) {
|
||||
return (
|
||||
<span className="hidden w-24 shrink-0 text-center text-[11px] text-muted-foreground/60 sm:block">
|
||||
{t("settings.skills.marketplaceNoTrend", { defaultValue: "No trend yet" })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const width = 96;
|
||||
const height = 30;
|
||||
const padding = 2;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = Math.max(max - min, 1);
|
||||
const points = values.map((value, index) => ({
|
||||
x: padding + (index / (values.length - 1)) * (width - padding * 2),
|
||||
y: padding + ((max - value) / range) * (height - padding * 2),
|
||||
}));
|
||||
const line = points.slice(1).reduce((path, point, index) => {
|
||||
const previous = points[index];
|
||||
const middle = (previous.x + point.x) / 2;
|
||||
return `${path} C ${middle} ${previous.y}, ${middle} ${point.y}, ${point.x} ${point.y}`;
|
||||
}, `M ${points[0].x} ${points[0].y}`);
|
||||
const area = `${line} L ${points.at(-1)?.x ?? width} ${height} L ${points[0].x} ${height} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block"
|
||||
role="img"
|
||||
aria-label="8-week install trend"
|
||||
>
|
||||
<title>8-week install trend</title>
|
||||
<path d={area} fill="currentColor" opacity="0.06" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendingSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-border/45 px-5" aria-hidden>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<div key={index} className="flex items-center gap-3 py-4">
|
||||
<div className="h-3 w-5 animate-pulse rounded bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3.5 w-48 max-w-[55%] animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-32 max-w-[40%] animate-pulse rounded bg-muted/70" />
|
||||
</div>
|
||||
<div className="h-8 w-[92px] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1038,7 +1038,7 @@ export function ThreadComposer({
|
||||
if (skillQuery !== null) {
|
||||
const query = skillQuery.text;
|
||||
return skills
|
||||
.filter((skill) => skill.available)
|
||||
.filter((skill) => skill.enabled !== false && skill.available)
|
||||
.flatMap((skill) => {
|
||||
const matchRank = skillMatchRank(skill, query);
|
||||
return matchRank === null
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fetchSkills } from "@/lib/api";
|
||||
import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
||||
import type { SkillSummary } from "@/lib/types";
|
||||
|
||||
export function useSkills(token: string): SkillSummary[] {
|
||||
@ -8,11 +9,21 @@ export function useSkills(token: string): SkillSummary[] {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSkills(token)
|
||||
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
|
||||
.catch(() => !cancelled && setSkills([]));
|
||||
const refresh = () => {
|
||||
fetchSkills(token)
|
||||
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
|
||||
.catch(() => !cancelled && setSkills([]));
|
||||
};
|
||||
const onSkillsChanged = (event: Event) => {
|
||||
const payload = (event as CustomEvent<unknown>).detail;
|
||||
if (!cancelled && isSkillsPayload(payload)) setSkills(payload.skills);
|
||||
};
|
||||
|
||||
refresh();
|
||||
window.addEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
|
||||
@ -26,7 +26,12 @@ import type {
|
||||
SettingsUpdate,
|
||||
SidebarStatePayload,
|
||||
SkillDetail,
|
||||
SkillActionPayload,
|
||||
SkillInstallPayload,
|
||||
SkillsPayload,
|
||||
SkillsSearchPayload,
|
||||
SkillsTrendsPayload,
|
||||
SkillsTrendingPayload,
|
||||
SlashCommand,
|
||||
SlashCommandLifecycle,
|
||||
TranscriptionSettingsUpdate,
|
||||
@ -310,6 +315,87 @@ export async function fetchSkillDetail(
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSkillEnabled(
|
||||
token: string,
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
base: string = "",
|
||||
): Promise<SkillActionPayload> {
|
||||
const params = new URLSearchParams({ name, enabled: String(enabled) });
|
||||
return request<SkillActionPayload>(
|
||||
`${base}/api/webui/skills/update?${params}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSkill(
|
||||
token: string,
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<SkillActionPayload> {
|
||||
const params = new URLSearchParams({ name });
|
||||
return request<SkillActionPayload>(
|
||||
`${base}/api/webui/skills/delete?${params}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchMarketplaceSkills(
|
||||
token: string,
|
||||
query: string,
|
||||
base: string = "",
|
||||
): Promise<SkillsSearchPayload> {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
return request<SkillsSearchPayload>(
|
||||
`${base}/api/webui/skills/search?${params}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTrendingMarketplaceSkills(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<SkillsTrendingPayload> {
|
||||
return request<SkillsTrendingPayload>(
|
||||
`${base}/api/webui/skills/trending`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchMarketplaceSkillTrends(
|
||||
token: string,
|
||||
skillIds: string[],
|
||||
base: string = "",
|
||||
): Promise<SkillsTrendsPayload> {
|
||||
const params = new URLSearchParams();
|
||||
skillIds.forEach((id) => params.append("id", id));
|
||||
return request<SkillsTrendsPayload>(
|
||||
`${base}/api/webui/skills/trends?${params}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function installMarketplaceSkill(
|
||||
token: string,
|
||||
source: string,
|
||||
skill: string,
|
||||
base: string = "",
|
||||
): Promise<SkillInstallPayload> {
|
||||
const params = new URLSearchParams({ source, skill });
|
||||
return request<SkillInstallPayload>(
|
||||
`${base}/api/webui/skills/install?${params}`,
|
||||
token,
|
||||
undefined,
|
||||
150_000,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
token: string,
|
||||
key: string,
|
||||
|
||||
18
webui/src/lib/skill-events.ts
Normal file
18
webui/src/lib/skill-events.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { SkillsPayload } from "@/lib/types";
|
||||
|
||||
export const SKILLS_CHANGED_EVENT = "nanobot:skills-changed";
|
||||
|
||||
export function isSkillsPayload(value: unknown): value is SkillsPayload {
|
||||
return (
|
||||
!!value
|
||||
&& typeof value === "object"
|
||||
&& Array.isArray((value as { skills?: unknown }).skills)
|
||||
);
|
||||
}
|
||||
|
||||
export function notifySkillsChanged(payload: SkillsPayload): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent<SkillsPayload>(SKILLS_CHANGED_EVENT, {
|
||||
detail: payload,
|
||||
}));
|
||||
}
|
||||
@ -178,6 +178,8 @@ export interface SkillSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
source: "workspace" | "builtin" | string;
|
||||
enabled?: boolean;
|
||||
deletable?: boolean;
|
||||
available: boolean;
|
||||
unavailable_reason?: string;
|
||||
}
|
||||
@ -189,13 +191,64 @@ export interface SkillRequirements {
|
||||
missing_env: string[];
|
||||
}
|
||||
|
||||
export interface SkillInstallOption {
|
||||
id: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface SkillDetail extends SkillSummary {
|
||||
requirements: SkillRequirements;
|
||||
install_options?: SkillInstallOption[];
|
||||
raw_markdown: string;
|
||||
}
|
||||
|
||||
export interface SkillsPayload { skills: SkillSummary[]; }
|
||||
|
||||
export interface SkillActionPayload extends SkillsPayload {
|
||||
last_action: {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
deleted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MarketplaceSkillSummary {
|
||||
id: string;
|
||||
skill_id: string;
|
||||
name: string;
|
||||
source: string;
|
||||
installs: number;
|
||||
url: string;
|
||||
installed: boolean;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface SkillsSearchPayload {
|
||||
query: string;
|
||||
skills: MarketplaceSkillSummary[];
|
||||
install_supported: boolean;
|
||||
}
|
||||
|
||||
export interface SkillsTrendingPayload {
|
||||
skills: MarketplaceSkillSummary[];
|
||||
period: "24h";
|
||||
install_supported: boolean;
|
||||
}
|
||||
|
||||
export interface SkillsTrendsPayload {
|
||||
trends: Record<string, number[]>;
|
||||
}
|
||||
|
||||
export interface SkillInstallPayload extends SkillsPayload {
|
||||
last_action: {
|
||||
installed: boolean;
|
||||
already_installed: boolean;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
|
||||
export interface AgentUIBlob {
|
||||
kind: string;
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
completeProviderOAuth,
|
||||
createModelConfiguration,
|
||||
createProviderSettings,
|
||||
deleteSkill,
|
||||
deleteModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
@ -14,6 +15,7 @@ import {
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchMarketplaceSkillTrends,
|
||||
fetchNanobotFeatures,
|
||||
fetchProviderModels,
|
||||
fetchSessionAutomations,
|
||||
@ -21,9 +23,11 @@ import {
|
||||
fetchSidebarState,
|
||||
fetchSkillDetail,
|
||||
fetchSkills,
|
||||
fetchTrendingMarketplaceSkills,
|
||||
fetchWebuiThread,
|
||||
fetchWorkspaces,
|
||||
importMcpConfig,
|
||||
installMarketplaceSkill,
|
||||
listSessions,
|
||||
listSlashCommands,
|
||||
loginProviderOAuth,
|
||||
@ -35,6 +39,7 @@ import {
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
searchMarketplaceSkills,
|
||||
startApiService,
|
||||
stopApiService,
|
||||
cancelChannelConnect,
|
||||
@ -49,6 +54,7 @@ import {
|
||||
updateNetworkSafetySettings,
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
updateSkillEnabled,
|
||||
updateWebSearchSettings,
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
@ -287,6 +293,72 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes skills.sh search queries", async () => {
|
||||
await searchMarketplaceSkills("tok", "React & testing");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/search?q=React+%26+testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches the skills.sh 24-hour leaderboard", async () => {
|
||||
await fetchTrendingMarketplaceSkills("tok");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/trending",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches skills.sh trend history independently", async () => {
|
||||
await fetchMarketplaceSkillTrends("tok", [
|
||||
"vercel-labs/skills/find-skills",
|
||||
"acme/skills/react",
|
||||
]);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills&id=acme%2Fskills%2Freact",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes skills.sh install coordinates", async () => {
|
||||
await installMarketplaceSkill("tok", "vercel-labs/agent-skills", "react-testing");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?source=vercel-labs%2Fagent-skills&skill=react-testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates and deletes installed skills with encoded names", async () => {
|
||||
await updateSkillEnabled("tok", "custom skill", false);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/update?name=custom+skill&enabled=false",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await deleteSkill("tok", "custom skill");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/delete?name=custom+skill",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1");
|
||||
|
||||
|
||||
@ -384,20 +384,39 @@ describe("App layout", () => {
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
{
|
||||
name: "cron",
|
||||
description: "Schedule reminders.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
},
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/github": {
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
requirements: {
|
||||
@ -406,8 +425,48 @@ describe("App layout", () => {
|
||||
missing_bins: ["gh"],
|
||||
missing_env: [],
|
||||
},
|
||||
install_options: [{
|
||||
id: "brew",
|
||||
kind: "brew",
|
||||
label: "Install GitHub CLI (brew)",
|
||||
command: "brew install gh",
|
||||
}],
|
||||
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
||||
},
|
||||
"/api/webui/skills/update?name=github&enabled=false": {
|
||||
skills: [
|
||||
{
|
||||
name: "cron",
|
||||
description: "Schedule reminders.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: false,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
},
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
last_action: {
|
||||
name: "github",
|
||||
enabled: false,
|
||||
deleted: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@ -419,9 +478,12 @@ describe("App layout", () => {
|
||||
fireEvent.click(skillsButton);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search installed skills" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Custom" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Built-in" })).toBeInTheDocument();
|
||||
expect(screen.getByText("cron")).toBeInTheDocument();
|
||||
expect(screen.getByText("github")).toBeInTheDocument();
|
||||
expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument();
|
||||
expect(screen.getByText("Needs setup")).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
|
||||
@ -439,11 +501,186 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Unavailable reason")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Missing CLI")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Raw SKILL.md"));
|
||||
expect(screen.getByText("Setup required")).toBeInTheDocument();
|
||||
expect(screen.getByText("brew install gh")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Missing CLI")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Check again" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Skill instructions"));
|
||||
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
|
||||
const enabledSwitch = screen.getByRole("switch", { name: "Disable github" });
|
||||
expect(enabledSwitch).toHaveAttribute("aria-checked", "true");
|
||||
fireEvent.click(enabledSwitch);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("switch", { name: "Enable github" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a custom skill from its detail sheet", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/custom-skill": {
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
requirements: {
|
||||
bins: [],
|
||||
env: [],
|
||||
missing_bins: [],
|
||||
missing_env: [],
|
||||
},
|
||||
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
|
||||
},
|
||||
"/api/webui/skills/delete?name=custom-skill": {
|
||||
skills: [],
|
||||
last_action: {
|
||||
name: "custom-skill",
|
||||
enabled: false,
|
||||
deleted: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Open details for custom-skill" }),
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: "custom-skill" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(screen.getByRole("heading", { name: "Delete custom-skill?" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Open details for custom-skill" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No matching skills.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("discovers and installs a skill from skills.sh", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/trending": {
|
||||
period: "24h",
|
||||
install_supported: true,
|
||||
skills: [{
|
||||
id: "vercel-labs/skills/find-skills",
|
||||
skill_id: "find-skills",
|
||||
name: "find-skills",
|
||||
source: "vercel-labs/skills",
|
||||
installs: 14_481,
|
||||
url: "https://skills.sh/vercel-labs/skills/find-skills",
|
||||
installed: false,
|
||||
rank: 18,
|
||||
}],
|
||||
},
|
||||
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills": {
|
||||
trends: {
|
||||
"vercel-labs/skills/find-skills": [20, 32, 28, 45, 41, 50, 62, 58],
|
||||
},
|
||||
},
|
||||
"/api/webui/skills/search?q=React": {
|
||||
query: "React",
|
||||
install_supported: true,
|
||||
skills: [{
|
||||
id: "acme/agent-skills/react-testing",
|
||||
skill_id: "react-testing",
|
||||
name: "React Testing",
|
||||
source: "acme/agent-skills",
|
||||
installs: 42,
|
||||
url: "https://skills.sh/acme/agent-skills/react-testing",
|
||||
installed: false,
|
||||
}],
|
||||
},
|
||||
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
||||
trends: { "acme/agent-skills/react-testing": [] },
|
||||
},
|
||||
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing": {
|
||||
skills: [
|
||||
{
|
||||
name: "react-testing",
|
||||
description: "Test React apps.",
|
||||
source: "workspace",
|
||||
available: true,
|
||||
},
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
],
|
||||
last_action: {
|
||||
installed: true,
|
||||
already_installed: false,
|
||||
name: "react-testing",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Discover" }));
|
||||
expect(await screen.findByRole("heading", { name: "Trending today" })).toBeInTheDocument();
|
||||
expect(screen.getByText("find-skills")).toBeInTheDocument();
|
||||
expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("img", { name: "8-week install trend" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills.sh" }), {
|
||||
target: { value: "React" },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("React Testing")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Install React Testing?" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: expect.any(String) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Installed" })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||
expect(screen.getByText("react-testing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Automations from the main sidebar", async () => {
|
||||
|
||||
@ -1478,12 +1478,29 @@ describe("ThreadComposer", () => {
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
skills={[{
|
||||
name: skillName,
|
||||
description: "Fetch and summarize the latest AI research papers every day",
|
||||
source: "builtin",
|
||||
available: true,
|
||||
}]}
|
||||
skills={[
|
||||
{
|
||||
name: skillName,
|
||||
description: "Fetch and summarize the latest AI research papers every day",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "arxiv-disabled",
|
||||
description: "Disabled research workflow",
|
||||
source: "builtin",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "arxiv-unavailable",
|
||||
description: "Unavailable research workflow",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
available: false,
|
||||
},
|
||||
]}
|
||||
slashCommands={COMMANDS}
|
||||
/>,
|
||||
);
|
||||
@ -1499,6 +1516,8 @@ describe("ThreadComposer", () => {
|
||||
const name = within(option).getByText(skillName);
|
||||
expect(name).not.toHaveClass("truncate");
|
||||
expect(within(option).queryByText(`$${skillName}`)).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("arxiv-disabled")).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("arxiv-unavailable")).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("/model")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user