Files
beaver_project/app-instance/backend/beaver/services/skillhub_service.py
steven_li 30ab74ffb2 feat(engine): 添加MCP连接管理和工具集成功能
- 集成MCP连接管理器,支持MCP服务器连接
- 添加多种内置工具:ClarifyTool、CronTool、DelegateTool、ExecuteCodeTool、
  PatchFileTool、ProcessTool、SendMessageTool、SpawnTool、TerminalTool、
  TodoTool、WebFetchTool、WebSearchTool、WriteFileTool等
- 实现工具注册和装配功能
- 添加技能选择上下文参数
- 支持思考模式控制参数thinking_enabled

feat(coordinator): 重构任务执行计划器参数命名

- 将learning_candidate_enabled重命名为allow_candidate_generation
- 更新TeamGraphScheduler中的参数传递
- 修改LocalAgentRunner中的相关参数处理
- 更新README文档中的相应描述

refactor(context): 标准化工具调用参数格式

- 添加_json导入用于参数序列化
- 实现_provider_tool_calls方法标准化OpenAI兼容的工具调用载荷
- 修复工具调用中参数非字符串类型的序列化问题

refactor(session): 优化消息历史记录过滤逻辑

- 修改get_messages_as_conversation为基于运行状态过滤消息
- 排除未完成、失败或错误结束的运行记录
- 改进对话历史的可见性控制机制

fix(store): 修复FTS索引重建逻辑

- 添加异常处理防止FTS索引创建失败
- 实现_rebuild_fts_index方法重新构建全文搜索索引
- 优化索引触发器和表的维护流程
2026-05-14 09:43:48 +08:00

249 lines
10 KiB
Python

"""SkillHub marketplace client and installer."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import posixpath
from typing import Any
import httpx
from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter
from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
SKILLHUB_BASE_URL = "https://skillhub.bwgdi.com"
SKILLHUB_API_BASE = f"{SKILLHUB_BASE_URL}/api/web"
@dataclass(slots=True)
class SkillHubService:
store: SkillSpecStore
timeout_seconds: int = 30
async def search(
self,
*,
q: str = "",
sort: str = "relevance",
page: int = 0,
size: int = 12,
namespace: str | None = None,
) -> dict[str, Any]:
params = {
"q": q,
"sort": sort,
"page": str(max(0, page)),
"size": str(max(1, min(size, 50))),
}
if namespace:
params["namespace"] = namespace.removeprefix("@")
data = await self._get_json("/skills", params=params)
payload = _unwrap(data)
if not isinstance(payload, dict):
payload = {}
items = [self._with_install_state(item) for item in list(payload.get("items") or [])]
return {
"items": items,
"total": int(payload.get("total") or len(items)),
"page": int(payload.get("page") or page),
"size": int(payload.get("size") or size),
}
async def detail(self, namespace: str, slug: str) -> dict[str, Any]:
data = await self._get_json(f"/skills/{namespace.removeprefix('@')}/{slug}")
payload = _unwrap(data)
item = self._with_install_state(payload if isinstance(payload, dict) else {})
return item
async def version(self, namespace: str, slug: str, version: str) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
detail = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions/{version}"))
files = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions/{version}/files"))
if not isinstance(detail, dict):
detail = {}
if not isinstance(files, list):
files = []
return {"detail": detail, "files": files}
async def install(self, namespace: str, slug: str, version: str | None = None) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
skill = await self.detail(namespace, slug)
selected_version = version or _published_version(skill)
if not selected_version:
raise ValueError("SkillHub skill has no published version")
version_payload = await self.version(namespace, slug, selected_version)
files = list(version_payload.get("files") or [])
contents: dict[str, str] = {}
for item in files:
file_path = _safe_posix_path(str(item.get("filePath") or item.get("path") or ""))
contents[file_path] = await self._get_text(
f"/skills/{namespace}/{slug}/versions/{selected_version}/file",
params={"path": file_path},
)
skill_content = contents.get("SKILL.md")
if not skill_content:
raise ValueError("SkillHub version does not contain SKILL.md")
frontmatter, body = parse_frontmatter(skill_content)
skill_name = str(frontmatter.get("name") or skill.get("slug") or slug).strip()
if not skill_name or "/" in skill_name or "\\" in skill_name or skill_name in {".", ".."}:
raise ValueError(f"Unsafe skill name from SkillHub: {skill_name}")
normalized_frontmatter = normalize_frontmatter(
{
**frontmatter,
"name": skill_name,
"description": frontmatter.get("description") or skill.get("summary") or skill_name,
}
)
rendered = _render_skill_content(normalized_frontmatter, body)
content_hash = canonical_hash(rendered)
existing = self.store.read_published_skill(skill_name)
existing_spec = self.store.get_skill_spec(skill_name)
if existing is not None and existing.version.content_hash == content_hash:
return {
"ok": True,
"skill_name": skill_name,
"version": existing.version.version,
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"installed_path": str(self.store.root / skill_name),
"already_installed": True,
}
next_version = self._next_version(skill_name)
now = datetime.now(timezone.utc).isoformat()
skill_version = SkillVersion(
skill_name=skill_name,
version=next_version,
content_hash=content_hash,
summary_hash=canonical_hash(strip_frontmatter(rendered).strip()),
created_at=now,
created_by="skillhub",
change_reason=f"Install SkillHub {namespace}/{slug}@{selected_version}",
parent_version=existing.version.version if existing is not None else None,
review_state="published",
frontmatter=normalized_frontmatter,
summary=summarize_skill_content(body),
tool_hints=self.store._extract_tool_hints(normalized_frontmatter),
provenance={
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"skillhub_version": selected_version,
"source_url": f"{SKILLHUB_BASE_URL}/space/{namespace}/{slug}",
},
)
self.store.write_skill_version(skill_version, rendered)
for file_path, content in contents.items():
if file_path == "SKILL.md":
continue
target = self.store.root / skill_name / "versions" / next_version / file_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
spec = existing_spec or SkillSpec(
name=skill_name,
display_name=str(skill.get("displayName") or skill_name),
description=str(normalized_frontmatter.get("description") or skill_name),
created_at=now,
updated_at=now,
current_version=next_version,
status="active",
tags=[],
owners=["skillhub"],
source_kind="skillhub",
lineage=[],
)
spec.current_version = next_version
spec.updated_at = now
spec.status = "active"
spec.source_kind = "skillhub"
if "skillhub" not in spec.owners:
spec.owners.append("skillhub")
self.store.write_skill_spec(spec)
self.store.set_current_version(skill_name, next_version)
published = self.store.read_index("published")
if skill_name not in published:
published.append(skill_name)
self.store.update_index("published", published)
return {
"ok": True,
"skill_name": skill_name,
"version": next_version,
"source": "skillhub",
"namespace": namespace,
"slug": slug,
"installed_path": str(self.store.root / skill_name),
"already_installed": False,
}
async def _get_json(self, path: str, *, params: dict[str, str] | None = None) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, trust_env=False) as client:
response = await client.get(f"{SKILLHUB_API_BASE}{path}", params=params)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
async def _get_text(self, path: str, *, params: dict[str, str]) -> str:
async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, trust_env=False) as client:
response = await client.get(f"{SKILLHUB_API_BASE}{path}", params=params)
response.raise_for_status()
return response.text
def _with_install_state(self, item: dict[str, Any]) -> dict[str, Any]:
result = dict(item)
slug = str(result.get("slug") or result.get("displayName") or "")
namespace = str(result.get("namespace") or "").removeprefix("@")
installed = self.store.get_skill_spec(slug) or self._find_installed_skillhub_spec(namespace, slug)
result["installed"] = installed is not None and installed.status == "active"
result["installed_version"] = installed.current_version if installed is not None else None
return result
def _find_installed_skillhub_spec(self, namespace: str, slug: str) -> SkillSpec | None:
for spec in self.store.list_skill_specs():
loaded = self.store.read_published_skill(spec.name)
provenance = loaded.version.provenance if loaded is not None else {}
if provenance.get("source") == "skillhub" and provenance.get("namespace") == namespace and provenance.get("slug") == slug:
return spec
return None
def _next_version(self, skill_name: str) -> str:
versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")]
numbers = [int(item[1:]) for item in versions if item[1:].isdigit()]
return f"v{(max(numbers) if numbers else 0) + 1:04d}"
def _unwrap(payload: dict[str, Any]) -> Any:
if "data" in payload:
return payload["data"]
return payload
def _published_version(item: dict[str, Any]) -> str | None:
for key in ("publishedVersion", "headlineVersion"):
value = item.get(key)
if isinstance(value, dict) and value.get("version"):
return str(value["version"])
return None
def _safe_posix_path(value: str) -> str:
cleaned = posixpath.normpath(value.replace("\\", "/")).lstrip("/")
if cleaned in {"", ".", ".."} or cleaned.startswith("../") or "/../" in cleaned:
raise ValueError(f"Unsafe SkillHub file path: {value}")
return cleaned
def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str:
lines = ["---"]
for key, value in normalize_frontmatter(frontmatter).items():
if isinstance(value, list):
lines.append(f"{key}:")
for item in value:
lines.append(f" - {item}")
else:
lines.append(f"{key}: {value}")
lines.extend(["---", "", body.strip()])
return "\n".join(lines).rstrip() + "\n"