mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-06 20:42:43 +08:00
feat: add searchable skills marketplace
This commit is contained in:
@@ -27,6 +27,8 @@ class PendingSkillsInteraction:
|
||||
view: str = "root"
|
||||
local_page: int = 0
|
||||
market_page: int = 0
|
||||
market_query: str = ""
|
||||
awaiting_input: Optional[str] = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@@ -162,7 +164,14 @@ class SkillsChain(ChainBase):
|
||||
source=source,
|
||||
username=None,
|
||||
)
|
||||
force = (arg_str or "").strip().lower() in {"refresh", "刷新"}
|
||||
normalized_arg = (arg_str or "").strip()
|
||||
force = normalized_arg.lower() in {"refresh", "刷新"}
|
||||
search_query = self._extract_market_search_query(normalized_arg) or (
|
||||
"" if force else normalized_arg
|
||||
)
|
||||
if search_query:
|
||||
request.view = "market"
|
||||
request.market_query = search_query
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
@@ -242,13 +251,22 @@ class SkillsChain(ChainBase):
|
||||
|
||||
if action == "root":
|
||||
request.view = "root"
|
||||
request.awaiting_input = None
|
||||
elif action == "installed":
|
||||
request.view = "installed"
|
||||
request.local_page = 0
|
||||
request.awaiting_input = None
|
||||
elif action == "market":
|
||||
request.view = "market"
|
||||
request.market_page = 0
|
||||
request.awaiting_input = None
|
||||
elif action == "search":
|
||||
request.view = "market"
|
||||
request.awaiting_input = "market-search"
|
||||
elif action == "clear-search":
|
||||
self._clear_market_search(request)
|
||||
elif action == "refresh":
|
||||
request.awaiting_input = None
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
@@ -261,16 +279,19 @@ class SkillsChain(ChainBase):
|
||||
)
|
||||
return True
|
||||
elif action == "page-next":
|
||||
request.awaiting_input = None
|
||||
if request.view == "installed":
|
||||
request.local_page += 1
|
||||
elif request.view == "market":
|
||||
request.market_page += 1
|
||||
elif action == "page-prev":
|
||||
request.awaiting_input = None
|
||||
if request.view == "installed":
|
||||
request.local_page = max(0, request.local_page - 1)
|
||||
elif request.view == "market":
|
||||
request.market_page = max(0, request.market_page - 1)
|
||||
elif action == "install" and index:
|
||||
request.awaiting_input = None
|
||||
success, message = self._install_market_skill(request, index)
|
||||
if success:
|
||||
self.post_message(
|
||||
@@ -293,6 +314,7 @@ class SkillsChain(ChainBase):
|
||||
)
|
||||
)
|
||||
elif action == "remove" and index:
|
||||
request.awaiting_input = None
|
||||
success, message = self._remove_local_skill(request, index)
|
||||
self.post_message(
|
||||
Notification(
|
||||
@@ -354,6 +376,7 @@ class SkillsChain(ChainBase):
|
||||
|
||||
if lowered in {"返回", "back"}:
|
||||
request.view = "root"
|
||||
request.awaiting_input = None
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
@@ -364,6 +387,7 @@ class SkillsChain(ChainBase):
|
||||
return True
|
||||
|
||||
if lowered in {"刷新", "refresh"}:
|
||||
request.awaiting_input = None
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
@@ -375,6 +399,7 @@ class SkillsChain(ChainBase):
|
||||
return True
|
||||
|
||||
if lowered in {"p", "prev", "上一页"}:
|
||||
request.awaiting_input = None
|
||||
if request.view == "installed":
|
||||
request.local_page = max(0, request.local_page - 1)
|
||||
elif request.view == "market":
|
||||
@@ -389,6 +414,7 @@ class SkillsChain(ChainBase):
|
||||
return True
|
||||
|
||||
if lowered in {"n", "next", "下一页"}:
|
||||
request.awaiting_input = None
|
||||
if request.view == "installed":
|
||||
request.local_page += 1
|
||||
elif request.view == "market":
|
||||
@@ -407,6 +433,11 @@ class SkillsChain(ChainBase):
|
||||
request.view = "installed"
|
||||
elif lowered in {"2", "市场", "market"}:
|
||||
request.view = "market"
|
||||
elif self._extract_market_search_query(normalized):
|
||||
self._apply_market_search(
|
||||
request,
|
||||
self._extract_market_search_query(normalized),
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
Notification(
|
||||
@@ -427,9 +458,49 @@ class SkillsChain(ChainBase):
|
||||
)
|
||||
return True
|
||||
|
||||
if lowered in {"清除搜索", "取消搜索", "clear", "clear search"}:
|
||||
if request.view == "market" or request.market_query:
|
||||
self._clear_market_search(request)
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
|
||||
if request.awaiting_input == "market-search":
|
||||
if lowered in {"取消", "cancel"}:
|
||||
request.awaiting_input = None
|
||||
else:
|
||||
search_query = self._extract_market_search_query(normalized) or normalized
|
||||
self._apply_market_search(request, search_query)
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
|
||||
search_query = self._extract_market_search_query(normalized)
|
||||
if search_query:
|
||||
self._apply_market_search(request, search_query)
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
|
||||
install_match = re.match(r"^(?:安装|装)\s*(\d+)$", normalized)
|
||||
remove_match = re.match(r"^(?:删除|删)\s*(\d+)$", normalized)
|
||||
if request.view == "market" and install_match:
|
||||
request.awaiting_input = None
|
||||
success, message = self._install_market_skill(
|
||||
request=request,
|
||||
page_index=int(install_match.group(1)),
|
||||
@@ -452,6 +523,7 @@ class SkillsChain(ChainBase):
|
||||
)
|
||||
return True
|
||||
if request.view == "installed" and remove_match:
|
||||
request.awaiting_input = None
|
||||
success, message = self._remove_local_skill(
|
||||
request=request,
|
||||
page_index=int(remove_match.group(1)),
|
||||
@@ -493,7 +565,7 @@ class SkillsChain(ChainBase):
|
||||
"""
|
||||
按当前市场页的可见序号安装技能,避免跨页序号歧义。
|
||||
"""
|
||||
market_skills = [skill for skill in self.skillhelper.list_market_skills() if not skill.installed]
|
||||
market_skills = self._get_market_skills(request=request)
|
||||
page_items, page, _ = self._page_items(
|
||||
items=market_skills,
|
||||
page=request.market_page,
|
||||
@@ -683,11 +755,10 @@ class SkillsChain(ChainBase):
|
||||
"""
|
||||
构建技能市场视图,仅展示尚未安装的技能。
|
||||
"""
|
||||
market_skills = [
|
||||
skill
|
||||
for skill in self.skillhelper.list_market_skills(force=force_market_refresh)
|
||||
if not skill.installed
|
||||
]
|
||||
market_skills = self._get_market_skills(
|
||||
request=request,
|
||||
force_market_refresh=force_market_refresh,
|
||||
)
|
||||
page_items, page, total_pages = self._page_items(
|
||||
items=market_skills,
|
||||
page=request.market_page,
|
||||
@@ -696,9 +767,21 @@ class SkillsChain(ChainBase):
|
||||
request.market_page = page
|
||||
|
||||
text_lines = [f"第 {page + 1}/{total_pages} 页,共 {len(market_skills)} 个可安装技能"]
|
||||
if request.market_query:
|
||||
text_lines.append(f"当前搜索:{request.market_query}")
|
||||
if request.awaiting_input == "market-search":
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
"搜索输入中:直接回复关键词即可筛选市场技能,回复 取消 结束输入。",
|
||||
]
|
||||
)
|
||||
if not page_items:
|
||||
text_lines.append("")
|
||||
text_lines.append("当前没有可安装的市场技能")
|
||||
if request.market_query:
|
||||
text_lines.append("当前搜索没有匹配的市场技能")
|
||||
else:
|
||||
text_lines.append("当前没有可安装的市场技能")
|
||||
else:
|
||||
for index, skill in enumerate(page_items, start=1):
|
||||
text_lines.extend(
|
||||
@@ -722,13 +805,30 @@ class SkillsChain(ChainBase):
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
"回复 安装 <序号> 安装技能,回复 刷新 重新拉取市场,回复 n/p 翻页,回复 返回 回到菜单,回复 退出 结束交互",
|
||||
"回复 搜索 <关键词> 筛选技能,回复 清除搜索 恢复全量列表,回复 安装 <序号> 安装技能,回复 刷新 重新拉取市场,回复 n/p 翻页,回复 返回 回到菜单,回复 退出 结束交互",
|
||||
]
|
||||
)
|
||||
|
||||
buttons = None
|
||||
if self._supports_interactive_buttons(request.channel):
|
||||
buttons = []
|
||||
search_row = []
|
||||
if request.market_query:
|
||||
search_row.append(
|
||||
{
|
||||
"text": "清除搜索",
|
||||
"callback_data": f"skills:{request.request_id}:clear-search",
|
||||
}
|
||||
)
|
||||
else:
|
||||
search_row.append(
|
||||
{
|
||||
"text": "搜索",
|
||||
"callback_data": f"skills:{request.request_id}:search",
|
||||
}
|
||||
)
|
||||
if search_row:
|
||||
buttons.append(search_row)
|
||||
for index, _skill in enumerate(page_items, start=1):
|
||||
buttons.append(
|
||||
[
|
||||
@@ -872,7 +972,60 @@ class SkillsChain(ChainBase):
|
||||
根据当前视图返回可执行的文本命令提示。
|
||||
"""
|
||||
if view == "market":
|
||||
return "请输入 安装 <序号>、刷新、n、p、返回 或 退出"
|
||||
return "请输入 搜索 <关键词>、清除搜索、安装 <序号>、刷新、n、p、返回 或 退出"
|
||||
if view == "installed":
|
||||
return "请输入 删除 <序号>、n、p、返回 或 退出"
|
||||
return "请输入 1、2、刷新 或 退出"
|
||||
return "请输入 1、2、搜索 <关键词>、刷新 或 退出"
|
||||
|
||||
def _get_market_skills(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
force_market_refresh: bool = False,
|
||||
) -> List[SkillInfo]:
|
||||
"""
|
||||
获取当前 /skills 会话可见的市场技能,并应用搜索词过滤。
|
||||
"""
|
||||
skills = [
|
||||
skill
|
||||
for skill in self.skillhelper.list_market_skills(force=force_market_refresh)
|
||||
if not skill.installed
|
||||
]
|
||||
if not request.market_query:
|
||||
return skills
|
||||
return self.skillhelper.filter_market_skills(
|
||||
skills=skills,
|
||||
query=request.market_query,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_market_search_query(text: str) -> str:
|
||||
"""
|
||||
从文本命令中提取市场搜索词,兼容“搜索/查找/查”前缀。
|
||||
"""
|
||||
normalized = (text or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
match = re.match(r"^(?:搜索|查找|查)\s+(.+)$", normalized)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
@staticmethod
|
||||
def _apply_market_search(
|
||||
request: PendingSkillsInteraction,
|
||||
query: str,
|
||||
) -> None:
|
||||
"""
|
||||
将会话切到市场搜索结果视图,并重置分页状态。
|
||||
"""
|
||||
request.view = "market"
|
||||
request.market_query = (query or "").strip()
|
||||
request.market_page = 0
|
||||
request.awaiting_input = None
|
||||
|
||||
@staticmethod
|
||||
def _clear_market_search(request: PendingSkillsInteraction) -> None:
|
||||
"""
|
||||
清除当前市场搜索状态,恢复全量市场列表。
|
||||
"""
|
||||
request.market_query = ""
|
||||
request.market_page = 0
|
||||
request.awaiting_input = None
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from urllib.parse import urlencode, urljoin, urlparse
|
||||
|
||||
from app.agent.middleware.skills import _parse_skill_metadata
|
||||
from app.core.cache import cached, fresh
|
||||
@@ -20,6 +21,10 @@ _SOURCE_META_FILENAME = ".moviepilot-skill-source.json"
|
||||
_DEFAULT_BRANCHES = ("main", "master")
|
||||
_MARKET_CACHE_TTL = 60 * 30
|
||||
_CLAWHUB_HOSTS = {"clawhub.ai", "www.clawhub.ai"}
|
||||
_CLAWHUB_CONVEX_CLIENT = "npm-1.35.1"
|
||||
_CLAWHUB_LIST_QUERY = "skills:listPublicPageV4"
|
||||
_CLAWHUB_LIST_PAGE_SIZE = 100
|
||||
_CLAWHUB_LIST_MAX_PAGES = 2
|
||||
_OFFICIAL_SKILL_REPOS = {
|
||||
"openai/skills",
|
||||
"anthropics/skills",
|
||||
@@ -316,6 +321,44 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def filter_market_skills(
|
||||
skills: List[SkillInfo],
|
||||
query: str,
|
||||
) -> List[SkillInfo]:
|
||||
"""
|
||||
按关键词过滤市场技能。
|
||||
|
||||
搜索范围覆盖技能 ID、名称、描述以及来源标签;多词查询按 AND 语义匹配,
|
||||
便于用户逐步缩小候选范围。
|
||||
"""
|
||||
normalized_query = (query or "").strip().lower()
|
||||
if not normalized_query:
|
||||
return skills
|
||||
|
||||
terms = [term for term in re.split(r"\s+", normalized_query) if term]
|
||||
if not terms:
|
||||
return skills
|
||||
|
||||
results: List[SkillInfo] = []
|
||||
for skill in skills:
|
||||
haystack = " ".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
skill.id,
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.source_label,
|
||||
skill.repo_name,
|
||||
skill.registry_name,
|
||||
],
|
||||
)
|
||||
).lower()
|
||||
if all(term in haystack for term in terms):
|
||||
results.append(skill)
|
||||
return results
|
||||
|
||||
@cached(maxsize=24, ttl=_MARKET_CACHE_TTL, skip_empty=True)
|
||||
def _list_market_source_skills(self, source: str) -> List[SkillInfo]:
|
||||
"""
|
||||
@@ -405,8 +448,17 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
|
||||
def _list_market_registry_skills(self, registry: dict) -> List[SkillInfo]:
|
||||
"""
|
||||
从注册表拉取技能列表,目前用于 ClawHub。
|
||||
从注册表拉取技能列表。
|
||||
|
||||
ClawHub 官方前端当前通过 Convex query 拉取公开技能列表,因此这里优先复用
|
||||
同一条查询链路;若运行时信息解析失败,再回退到旧的 REST 风格接口。
|
||||
"""
|
||||
parsed = urlparse((registry.get("registry_url") or "").rstrip("/"))
|
||||
if parsed.netloc.lower() in _CLAWHUB_HOSTS:
|
||||
skills = self._list_clawhub_registry_skills(registry)
|
||||
if skills:
|
||||
return skills
|
||||
|
||||
response = self._request_registry(
|
||||
url=f"{registry['api_base']}/skills",
|
||||
params={"limit": 200, "sort": "installsAllTime"},
|
||||
@@ -599,29 +651,94 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
return [item for item in items if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
def _list_clawhub_registry_skills(self, registry: dict) -> List[SkillInfo]:
|
||||
"""
|
||||
按 ClawHub 官方前端的调用方式,通过 Convex query 获取公开技能列表。
|
||||
"""
|
||||
runtime_env = self._discover_clawhub_runtime_env(registry["registry_url"])
|
||||
convex_url = (runtime_env or {}).get("convex_url")
|
||||
if not convex_url:
|
||||
return []
|
||||
|
||||
results: Dict[str, SkillInfo] = {}
|
||||
cursor = None
|
||||
for _ in range(_CLAWHUB_LIST_MAX_PAGES):
|
||||
args = {
|
||||
"numItems": _CLAWHUB_LIST_PAGE_SIZE,
|
||||
"sort": "downloads",
|
||||
"dir": "desc",
|
||||
"nonSuspiciousOnly": True,
|
||||
}
|
||||
if cursor:
|
||||
args["cursor"] = cursor
|
||||
|
||||
response = self._request_convex_query(
|
||||
deployment_url=convex_url,
|
||||
path=_CLAWHUB_LIST_QUERY,
|
||||
args=args,
|
||||
)
|
||||
if not response:
|
||||
break
|
||||
|
||||
payload = self._load_json_response(response)
|
||||
value = payload.get("value")
|
||||
if not isinstance(value, dict):
|
||||
break
|
||||
|
||||
page = value.get("page")
|
||||
if not isinstance(page, list):
|
||||
break
|
||||
|
||||
for item in page:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
skill = self._build_registry_skill(item, registry)
|
||||
if skill and skill.id not in results:
|
||||
results[skill.id] = skill
|
||||
|
||||
if not value.get("hasMore") or not value.get("nextCursor"):
|
||||
break
|
||||
cursor = value["nextCursor"]
|
||||
|
||||
return list(results.values())
|
||||
|
||||
def _build_registry_skill(
|
||||
self, item: dict, registry: dict
|
||||
) -> Optional[SkillInfo]:
|
||||
"""
|
||||
将注册表返回的条目转换为统一的 SkillInfo。
|
||||
"""
|
||||
skill_data = item.get("skill") if isinstance(item.get("skill"), dict) else item
|
||||
slug = (
|
||||
item.get("slug")
|
||||
skill_data.get("slug")
|
||||
or item.get("slug")
|
||||
or skill_data.get("id")
|
||||
or item.get("id")
|
||||
or item.get("name")
|
||||
or item.get("title")
|
||||
or skill_data.get("name")
|
||||
or skill_data.get("displayName")
|
||||
or skill_data.get("title")
|
||||
)
|
||||
if not slug:
|
||||
return None
|
||||
|
||||
name = item.get("name") or item.get("title") or slug
|
||||
name = (
|
||||
skill_data.get("name")
|
||||
or skill_data.get("displayName")
|
||||
or item.get("displayName")
|
||||
or skill_data.get("title")
|
||||
or item.get("title")
|
||||
or slug
|
||||
)
|
||||
description = (
|
||||
item.get("description")
|
||||
skill_data.get("description")
|
||||
or skill_data.get("summary")
|
||||
or item.get("description")
|
||||
or item.get("summary")
|
||||
or skill_data.get("excerpt")
|
||||
or item.get("excerpt")
|
||||
or ""
|
||||
)
|
||||
owner_handle = self._extract_registry_owner_handle(item)
|
||||
owner_handle = item.get("ownerHandle") or self._extract_registry_owner_handle(item)
|
||||
page_path = f"/{owner_handle}/{slug}" if owner_handle else f"/skills/{slug}"
|
||||
|
||||
return SkillInfo(
|
||||
@@ -637,7 +754,8 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
registry_url=registry["registry_url"],
|
||||
registry_name=registry["registry_name"],
|
||||
registry_slug=str(slug),
|
||||
download_url=item.get("downloadUrl")
|
||||
download_url=skill_data.get("downloadUrl")
|
||||
or item.get("downloadUrl")
|
||||
or self._build_registry_download_url(registry["api_base"], str(slug)),
|
||||
installed=False,
|
||||
removable=False,
|
||||
@@ -685,6 +803,74 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
return None
|
||||
return response.content
|
||||
|
||||
@cached(maxsize=4, ttl=_MARKET_CACHE_TTL, skip_empty=True)
|
||||
def _discover_clawhub_runtime_env(self, registry_url: str) -> Optional[dict]:
|
||||
"""
|
||||
从 ClawHub 首页的 runtime env 脚本中提取当前生效的 Convex 部署地址。
|
||||
"""
|
||||
response = self._request_registry(url=registry_url)
|
||||
if response is None or response.status_code != 200:
|
||||
return None
|
||||
|
||||
html = self._read_response_text(response)
|
||||
runtime_asset_path = self._extract_runtime_env_asset_path(html)
|
||||
if not runtime_asset_path:
|
||||
return None
|
||||
|
||||
runtime_asset_url = urljoin(f"{registry_url.rstrip('/')}/", runtime_asset_path)
|
||||
asset_response = self._request_registry(url=runtime_asset_url)
|
||||
if asset_response is None or asset_response.status_code != 200:
|
||||
return None
|
||||
|
||||
script = self._read_response_text(asset_response)
|
||||
convex_url = self._extract_runtime_env_value(script, "VITE_CONVEX_URL")
|
||||
convex_site_url = self._extract_runtime_env_value(script, "VITE_CONVEX_SITE_URL")
|
||||
if not convex_url and not convex_site_url:
|
||||
return None
|
||||
return {
|
||||
"convex_url": convex_url.rstrip("/") if convex_url else None,
|
||||
"convex_site_url": convex_site_url.rstrip("/") if convex_site_url else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _read_response_text(response) -> str:
|
||||
"""
|
||||
尽量稳定地把 requests 响应或测试桩响应转换成文本。
|
||||
"""
|
||||
text = getattr(response, "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
|
||||
content = getattr(response, "content", b"")
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="ignore")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_runtime_env_asset_path(html: str) -> Optional[str]:
|
||||
"""
|
||||
从 ClawHub 首页 HTML 中定位 runtime env 资源路径。
|
||||
"""
|
||||
match = re.search(r'"/assets/runtimeEnv-[^"]+\.js"', html or "")
|
||||
if not match:
|
||||
return None
|
||||
return match.group(0).strip('"')
|
||||
|
||||
@staticmethod
|
||||
def _extract_runtime_env_value(script: str, key: str) -> Optional[str]:
|
||||
"""
|
||||
从运行时脚本中提取指定环境变量的值。
|
||||
"""
|
||||
match = re.search(
|
||||
rf"{re.escape(key)}:\s*['\"`]([^'\"`]+)['\"`]",
|
||||
script or "",
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1).strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_skill_archive(zf: zipfile.ZipFile, target_dir: Path) -> bool:
|
||||
"""
|
||||
@@ -744,6 +930,48 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
logger.warning("下载技能市场仓库失败:%s", repo["repo_url"])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _request_convex_query(
|
||||
deployment_url: str,
|
||||
path: str,
|
||||
args: dict,
|
||||
timeout: int = 30,
|
||||
):
|
||||
"""
|
||||
以官方前端相同的请求格式调用 Convex query 接口。
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Convex-Client": _CLAWHUB_CONVEX_CLIENT,
|
||||
}
|
||||
payload = {
|
||||
"path": path,
|
||||
"format": "convex_encoded_json",
|
||||
"args": [args or {}],
|
||||
}
|
||||
|
||||
strategies = []
|
||||
if settings.PROXY_HOST:
|
||||
strategies.append({"proxies": settings.PROXY, "timeout": timeout})
|
||||
strategies.append({"timeout": timeout})
|
||||
|
||||
for kwargs in strategies:
|
||||
try:
|
||||
response = RequestUtils(headers=headers, **kwargs).post_res(
|
||||
url=f"{deployment_url.rstrip('/')}/api/query",
|
||||
json=payload,
|
||||
raise_exception=True,
|
||||
)
|
||||
if response is not None and response.status_code == 200:
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"请求 Convex 技能列表失败:%s/api/query - %s",
|
||||
deployment_url.rstrip("/"),
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _request_registry(
|
||||
url: str,
|
||||
|
||||
@@ -159,6 +159,65 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
self.assertIn("内置技能", remove_message)
|
||||
|
||||
def test_skillhelper_lists_clawhub_registry_skills(self):
|
||||
helper = SkillHelper()
|
||||
response = _FakeResponse(
|
||||
payload={
|
||||
"status": "success",
|
||||
"value": {
|
||||
"hasMore": False,
|
||||
"nextCursor": None,
|
||||
"page": [
|
||||
{
|
||||
"ownerHandle": "openclaw",
|
||||
"skill": {
|
||||
"slug": "weather-forecast",
|
||||
"displayName": "Weather Forecast",
|
||||
"summary": "Forecast weather from ClawHub",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
helper,
|
||||
"_discover_clawhub_runtime_env",
|
||||
return_value={"convex_url": "https://wry-manatee-359.convex.cloud"},
|
||||
), patch.object(helper, "_request_convex_query", return_value=response):
|
||||
skills = helper._list_market_source_skills("https://clawhub.ai")
|
||||
|
||||
self.assertEqual(len(skills), 1)
|
||||
self.assertEqual(skills[0].id, "weather-forecast")
|
||||
self.assertEqual(skills[0].name, "Weather Forecast")
|
||||
self.assertEqual(skills[0].source_type, "registry")
|
||||
self.assertEqual(skills[0].registry_name, "ClawHub")
|
||||
self.assertEqual(skills[0].source_label, "社区注册表 · ClawHub")
|
||||
self.assertIn("/openclaw/weather-forecast", skills[0].path)
|
||||
|
||||
def test_skillhelper_filters_market_skills_by_query(self):
|
||||
helper = SkillHelper()
|
||||
skills = [
|
||||
SkillInfo(
|
||||
id="weather-forecast",
|
||||
name="Weather Forecast",
|
||||
description="Forecast weather from ClawHub",
|
||||
source_label="社区注册表 · ClawHub",
|
||||
),
|
||||
SkillInfo(
|
||||
id="github-tools",
|
||||
name="GitHub Tools",
|
||||
description="Manage pull requests",
|
||||
source_label="官方仓库 · openai/skills",
|
||||
),
|
||||
]
|
||||
|
||||
filtered = helper.filter_market_skills(skills=skills, query="weather clawhub")
|
||||
|
||||
self.assertEqual(len(filtered), 1)
|
||||
self.assertEqual(filtered[0].id, "weather-forecast")
|
||||
|
||||
def test_skillhelper_falls_back_to_rest_registry_listing_when_runtime_missing(self):
|
||||
helper = SkillHelper()
|
||||
response = _FakeResponse(
|
||||
payload={
|
||||
@@ -173,7 +232,9 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(helper, "_request_registry", return_value=response):
|
||||
with patch.object(
|
||||
helper, "_discover_clawhub_runtime_env", return_value=None
|
||||
), patch.object(helper, "_request_registry", return_value=response):
|
||||
skills = helper._list_market_source_skills("https://clawhub.ai")
|
||||
|
||||
self.assertEqual(len(skills), 1)
|
||||
@@ -261,6 +322,50 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
self.assertIn("社区源,安装前请自行甄别安全性", text)
|
||||
self.assertIn("ClawHub 属于社区注册表", text)
|
||||
|
||||
def test_skills_chain_market_view_filters_by_search_query(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
username="tester",
|
||||
)
|
||||
request.view = "market"
|
||||
request.market_query = "weather"
|
||||
|
||||
with patch.object(
|
||||
chain.skillhelper,
|
||||
"list_market_skills",
|
||||
return_value=[
|
||||
SkillInfo(
|
||||
id="weather-forecast",
|
||||
name="Weather Forecast",
|
||||
description="Forecast weather from ClawHub",
|
||||
source_type="registry",
|
||||
source_label="社区注册表 · ClawHub",
|
||||
registry_name="ClawHub",
|
||||
registry_url="https://clawhub.ai",
|
||||
registry_slug="weather-forecast",
|
||||
),
|
||||
SkillInfo(
|
||||
id="github-tools",
|
||||
name="GitHub Tools",
|
||||
description="Manage pull requests",
|
||||
source_type="market",
|
||||
source_label="官方仓库 · openai/skills",
|
||||
repo_name="openai/skills",
|
||||
),
|
||||
],
|
||||
):
|
||||
title, text, buttons = chain._build_market_view(request=request)
|
||||
|
||||
self.assertEqual(title, "技能市场")
|
||||
self.assertIn("当前搜索:weather", text)
|
||||
self.assertIn("weather-forecast", text)
|
||||
self.assertNotIn("github-tools", text)
|
||||
self.assertTrue(buttons)
|
||||
self.assertEqual(buttons[0][0]["callback_data"], f"skills:{request.request_id}:clear-search")
|
||||
|
||||
def test_skills_chain_root_view_uses_friendly_source_labels(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
@@ -283,6 +388,79 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
self.assertIn("社区注册表 · ClawHub", text)
|
||||
self.assertIn("官方仓库 · openai/skills", text)
|
||||
|
||||
def test_skills_chain_callback_enters_search_input_mode(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
with patch.object(chain, "_render_interaction") as render:
|
||||
handled = chain.handle_callback_interaction(
|
||||
callback_data=f"skills:{request.request_id}:search",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
self.assertTrue(handled)
|
||||
self.assertEqual(request.view, "market")
|
||||
self.assertEqual(request.awaiting_input, "market-search")
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_text_search_updates_market_query(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
username="tester",
|
||||
)
|
||||
request.view = "market"
|
||||
|
||||
with patch.object(chain, "_render_interaction") as render:
|
||||
handled = chain.handle_text_interaction(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
text="搜索 weather",
|
||||
)
|
||||
|
||||
self.assertTrue(handled)
|
||||
self.assertEqual(request.market_query, "weather")
|
||||
self.assertEqual(request.market_page, 0)
|
||||
self.assertIsNone(request.awaiting_input)
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_followup_text_applies_search_when_awaiting_input(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
username="tester",
|
||||
)
|
||||
request.view = "market"
|
||||
request.awaiting_input = "market-search"
|
||||
|
||||
with patch.object(chain, "_render_interaction") as render:
|
||||
handled = chain.handle_text_interaction(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
text="calendar",
|
||||
)
|
||||
|
||||
self.assertTrue(handled)
|
||||
self.assertEqual(request.market_query, "calendar")
|
||||
self.assertIsNone(request.awaiting_input)
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_updates_buttons_via_edit_message(self):
|
||||
chain = SkillsChain()
|
||||
buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]]
|
||||
|
||||
Reference in New Issue
Block a user